From 1bdc917ca57c5a55c3994ad1d8b7e18d6cf9d126 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 14:54:47 +0800 Subject: [PATCH 01/13] feat: choose a model's thinking effort per session, on every backend (#417) One scale (Default, Off, Minimal, Low, Medium, High, XHigh, Max), mapped to each backend's own setting: - Claude: the Agent SDK's `effort` on adaptive thinking (Off disables thinking), changed live with applyFlagSettings; to or from Off rebuilds the query, which resumes the conversation. - Codex: `effort` on every turn/start (none = null: codex keeps a turn's). - OpenAI: `reasoning_effort`; llama.cpp: the chat template's `enable_thinking` / `reasoning_effort`; Gemini: `thinkingConfig` (a budget for 2.5, a level for 3.x). - Qwen: `effort`, live with setEffort; pi: `set_thinking_level`, live. - Gemini CLI: a settings overlay of its usual home redefining its two chat aliases' thinking; it takes it only at startup, so a change rebuilds it before the next turn. Per session like the model: `session.set_effort` (session:send), effort on `session.create` and SessionInfo, persisted (sessions.effort), and `session.defaultEffort` / CODEOID_DEFAULT_EFFORT for new sessions. Never cuts a running turn short. The model list carries each model's efforts where the backend reports them (Claude, Codex, pi); elsewhere every level is offered, and a refused one fails the turn with a note. Web UI: an effort selector next to the model picker; TUI: /effort and a picker; Telegram: /effort; CLI: `codeoid new --effort`. docs/thinking-effort.md says what each level means on each backend. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 + CLAUDE.md | 3 +- docs/thinking-effort.md | 65 ++++++ packages/core/src/slash.test.ts | 13 +- packages/core/src/slash.ts | 69 ++++++- packages/protocol/src/schemas.test.ts | 1 + packages/protocol/src/schemas.ts | 12 +- packages/protocol/src/types.ts | 43 ++++ src/cli.ts | 18 +- src/config.ts | 10 + src/daemon/account-apply.ts | 71 ++++++- src/daemon/providers/acp/index.ts | 7 +- src/daemon/providers/claude/index.ts | 69 ++++++- src/daemon/providers/codex/index.ts | 54 ++++- src/daemon/providers/effort.ts | 13 ++ src/daemon/providers/gemini/index.ts | 30 ++- src/daemon/providers/interface.ts | 23 +++ src/daemon/providers/llamacpp/index.ts | 17 +- src/daemon/providers/openai/index.ts | 19 +- src/daemon/providers/pi/index.ts | 48 ++++- src/daemon/providers/qwen/index.ts | 29 ++- src/daemon/session-manager.ts | 16 ++ src/daemon/session.ts | 71 ++++++- src/daemon/store.ts | 16 +- src/frontends/telegram/index.ts | 34 ++- src/terminal/client.ts | 3 + src/tests/fixtures/fake-codex.ts | 14 +- src/tests/fixtures/fake-pi.ts | 14 +- src/tests/provider-claude.test.ts | 82 ++++++++ src/tests/provider-codex.test.ts | 33 ++- src/tests/provider-openai.test.ts | 15 ++ src/tests/provider-pi.test.ts | 21 +- src/tests/thinking-effort.test.ts | 264 ++++++++++++++++++++++++ src/tui/App.tsx | 34 ++- src/tui/components/BackendPicker.tsx | 37 +++- src/tui/components/Modal.tsx | 18 +- src/tui/components/SlashHint.tsx | 1 + src/tui/types.ts | 2 +- src/tui/ws.ts | 6 + web/src/components/HelpModal.tsx | 1 + web/src/components/SessionControls.tsx | 96 ++++++++- web/src/components/prompt/PromptBox.tsx | 3 +- 42 files changed, 1342 insertions(+), 60 deletions(-) create mode 100644 docs/thinking-effort.md create mode 100644 src/daemon/providers/effort.ts create mode 100644 src/tests/thinking-effort.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2716c94b..770fa2fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Choose how hard the model thinks, per session, on every backend** (#417). + One scale (Default, Off, Minimal, Low, Medium, High, Extra high, Max), mapped to each backend's own setting: Claude's effort, Codex's and OpenAI's reasoning effort, Qwen's and pi's levels, Gemini's thinking budget or level, llama.cpp's chat-template switches, and the Gemini CLI's model settings. + Set it from the web UI's **effort** selector, `/effort` (web, TUI, Telegram), `codeoid new --effort`, or `session.defaultEffort` for new sessions. + It stays with the session, like its model; it applies live where the backend can take it, otherwise from the next turn, and never cuts a running turn short. + The pickers offer the levels each model takes where the backend reports them (Claude, Codex, pi); elsewhere every level, marked as unknown. + docs/thinking-effort.md says what each level means on each backend. + - **An MCP server added through Claude reaches every backend in that directory** (#395). codeoid imported only Claude's global servers, once at start; a `claude mcp add` (local scope) or a project's `.mcp.json` stayed Claude-only. Now each session sees, on every backend, its directory's servers too: your own always, a project's once approved — in Claude Code (read only from your `~/.claude.json`), or in codeoid after reviewing the entry in full (the drawer's **Review & approve**, or `codeoid mcp approve`, which prints it and approves only with `--yes `), bound to exactly the entry shown. diff --git a/CLAUDE.md b/CLAUDE.md index 3fa73918..5a27ad46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,7 @@ bun run lint # biome check codeoid start # daemon + all frontends codeoid ls # list sessions -codeoid new [d] # create session (workdir required unless --worktree) +codeoid new [d] # create session (workdir required unless --worktree); --model, --effort codeoid attach # interactive attach (with scrollback replay) codeoid send # one-shot message codeoid interrupt @@ -211,6 +211,7 @@ TELEGRAM_ALLOWED_USER_IDS=123,456 # Required with bot token CODEOID_MCP_CONTROL_ENABLED=false # mount /mcp/control (docs/mcp-control.md) CODEOID_MCP_CONTROL_ACTIONS=false # also allow create / send / interrupt / destroy there CODEOID_ROUTINES_ENABLED=true # scheduler + routine commands (docs/routines.md) +CODEOID_DEFAULT_EFFORT= # thinking effort for new sessions: off|minimal|low|medium|high|xhigh|max (docs/thinking-effort.md) # Claude auth: uses your existing `claude login` session (Pro/Max subscription). # No ANTHROPIC_API_KEY needed if you're logged in via Claude Code CLI. diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md new file mode 100644 index 00000000..b5234c82 --- /dev/null +++ b/docs/thinking-effort.md @@ -0,0 +1,65 @@ +# Thinking effort + +How hard a session's model thinks before it answers, on one scale for every backend. + +## The scale + +**Default, Off, Minimal, Low, Medium, High, Extra high (`xhigh`), Max.** + +**Default** sends nothing: the backend picks, as it always has. +Every other level is mapped to the backend's own setting (below). +A level a backend has no exact match for goes to its nearest one. + +## Setting it + +- **Web UI:** the **effort** selector next to the model picker, or `/effort` in the prompt box. +- **TUI:** `/effort` opens a picker; `/effort high` (or `off`, …, `max`, `default`) sets it directly. +- **Telegram:** `/effort` shows it; `/effort ` sets it. +- **CLI:** `codeoid new --effort high`. +- **Config:** `session.defaultEffort` (or `CODEOID_DEFAULT_EFFORT`) for sessions created without one. + +It belongs to the session, like its model: it survives a daemon restart, and it stays the same across a backend switch, since the scale is the same everywhere. +A session resumed after a restart keeps its own; the config default applies only to new sessions. +Changing it needs `session:send` on your own session. + +## Which levels a model takes + +Where the backend says, the pickers offer only the model's own levels: + +- Claude reports each model's levels (and whether it takes effort at all). +- Codex reports each model's supported efforts and its default. +- pi reports which models reason and which levels each takes. + +The others (OpenAI and OpenAI-compatible endpoints, llama.cpp, Qwen, Gemini, the Gemini CLI) don't, so every level is offered and marked "support unknown". +A level the server refuses fails the turn with a note saying so; pick another, or Default. + +## When a change applies + +A change never cuts a running turn short. + +- **Now**, on the running backend: Claude (except to or from Off), Qwen (except back to Default), pi. +- **From the next turn**: Codex, OpenAI, llama.cpp, Gemini — they take it with every request — and the exceptions above, whose next turn starts a fresh query that resumes the conversation. +- **After a restart, before the next turn**: the Gemini CLI, which reads it only from its settings at startup. It resumes the conversation, as after a model change. + +## What each level means on each backend + +| codeoid | Claude | Codex | OpenAI | Qwen | pi | Gemini 2.5 | Gemini 3 / Gemini CLI 3 | llama.cpp | +|---|---|---|---|---|---|---|---|---| +| Off | thinking disabled | `none` | `none` | `low` (can't turn off) | `off` | budget 0 (Pro: 128) | `minimal` | `enable_thinking: false` | +| Minimal | `low` | `minimal` | `minimal` | `low` | `minimal` | budget 512 | `minimal` | `low` | +| Low | `low` | `low` | `low` | `low` | `low` | budget 2048 | `low` | `low` | +| Medium | `medium` | `medium` | `medium` | `medium` | `medium` | budget 8192 | `medium` | `medium` | +| High | `high` | `high` | `high` | `high` | `high` | budget 16384 | `high` | `high` | +| Extra high | `xhigh` | `xhigh` | `xhigh` | `xhigh` | `xhigh` | budget 24576 | `high` | `high` | +| Max | `max` | `max` | `xhigh` | `max` | `max` | budget 32768 (Flash: 24576) | `high` | `high` | + +Notes: + +- **Claude** keeps adaptive thinking, with readable summaries, at every level but Off. +- **OpenAI** sends `reasoning_effort`; a model that doesn't reason rejects it. +- **Gemini** sends `thinkingConfig` per request: a token budget for 2.5 models, a level for 3.x; other models get nothing. +- **The Gemini CLI** on 2.5 models takes the Gemini 2.5 budgets, but one that every 2.5 model accepts, since the settings can't know which runs: Off is 128 (Pro's minimum) and Max is 24576 (Flash's maximum). + It runs with a settings overlay of its usual home (an account's, or yours) whose `chat-base-2.5` and `chat-base-3` aliases carry the budget or level; logins and chats are linked, so nothing else changes. + The overlays live under `~/.codeoid/accounts/gemini-cli-effort/`. +- **llama.cpp** sends both through the model's chat template (`chat_template_kwargs`): `enable_thinking` for Qwen3-style templates, `reasoning_effort` (low / medium / high) for gpt-oss-style ones. + A template that reads neither ignores them. diff --git a/packages/core/src/slash.test.ts b/packages/core/src/slash.test.ts index 9488e5ec..94c83e9c 100644 --- a/packages/core/src/slash.test.ts +++ b/packages/core/src/slash.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, mock } from "bun:test"; -import { dispatchSlash, parseSlash, type SlashContext } from "./slash.js"; +import { dispatchSlash, effortChangeSummary, parseSlash, type SlashContext } from "./slash.js"; function ctx(): SlashContext & { sent: unknown[] } { const sent: unknown[] = []; @@ -76,6 +76,17 @@ describe("parseSlash", () => { }); }); + it("parses /effort: a level, default, or the picker (#417)", () => { + expect(parseSlash("/effort")).toEqual({ kind: "effort-picker" }); + expect(parseSlash("/effort High")).toEqual({ kind: "effort", effort: "high" }); + expect(parseSlash("/effort extra-high")).toEqual({ kind: "effort", effort: "xhigh" }); + expect(parseSlash("/effort off")).toEqual({ kind: "effort", effort: "off" }); + expect(parseSlash("/effort default")).toEqual({ kind: "effort", effort: null }); + expect(() => parseSlash("/effort turbo")).toThrow(/off\|minimal/); + expect(effortChangeSummary({ effort: "max", applies: "restart" })).toMatch(/restarts/); + expect(effortChangeSummary({ effort: null, applies: "next-turn" })).toBe("Thinking effort: the backend's default, from the next turn."); + }); + it("bare /model opens the picker instead of erroring", () => { expect(parseSlash("/model")).toEqual({ kind: "model-picker" }); }); diff --git a/packages/core/src/slash.ts b/packages/core/src/slash.ts index 64e681f9..48b1fa4d 100644 --- a/packages/core/src/slash.ts +++ b/packages/core/src/slash.ts @@ -15,12 +15,15 @@ * thin context object so we can mock send/newRequestId in tests. */ -import type { - ClientMessage, - SessionInfo, - SessionMode, - McpReconnectResult, - McpRefreshResult, +import { + EFFORT_LEVELS, + type ClientMessage, + type EffortChangeResult, + type EffortLevel, + type SessionInfo, + type SessionMode, + type McpReconnectResult, + type McpRefreshResult, } from "@highflame/codeoid-protocol"; export type SlashCommand = @@ -32,6 +35,8 @@ export type SlashCommand = | { kind: "mode"; mode: SessionMode; maxTurns?: number } | { kind: "model"; model: string; fallback?: string | null } | { kind: "model-picker" } + | { kind: "effort"; effort: EffortLevel | null } + | { kind: "effort-picker" } | { kind: "provider"; providerId: string } | { kind: "account"; account: string | null } | { kind: "help" } @@ -117,6 +122,11 @@ export function parseSlash(raw: string, opts?: ParseSlashOptions): SlashCommand ...(fallback !== undefined ? { fallback } : {}), }; } + case "effort": { + // Thinking effort (#417). Bare `/effort` opens the picker. + if (rest.length === 0) return { kind: "effort-picker" }; + return { kind: "effort", effort: parseEffort(rest[0]!) }; + } case "provider": { // Switch the focused session's BACKEND (e.g. `/provider pi`). The // daemon validates the id fail-closed and rejects mid-turn switches. @@ -199,6 +209,7 @@ export interface SlashContext { /** Optional UI hooks — caller provides as needed. */ showHelp?: () => void; showModelPicker?: () => void; + showEffortPicker?: () => void; showIdentity?: () => void; showCapabilities?: (tab: "agents" | "skills" | "mcp" | "hooks") => void; showSettings?: () => void; @@ -292,6 +303,24 @@ export function dispatchSlash(cmd: SlashCommand, ctx: SlashContext): void { case "model-picker": ctx.showModelPicker?.(); return; + case "effort-picker": + ctx.showEffortPicker?.(); + return; + case "effort": { + const msg: ClientMessage = { type: "session.set_effort", id: ctx.newRequestId(), sessionId: ctx.sessionId, effort: cmd.effort }; + if (!ctx.request) { + ctx.send(msg); + return; + } + ctx + .request(msg) + .then((resp) => { + const r = (resp as { data?: EffortChangeResult } | undefined)?.data; + if (r) ctx.report?.(effortChangeSummary(r)); + }) + .catch((e) => ctx.report?.(e instanceof Error ? e.message : String(e))); + return; + } case "provider": fire({ type: "session.set_provider", @@ -402,3 +431,31 @@ export function mcpRefreshSummary(r: McpRefreshResult): string { ? "MCP servers changed: work is running, so they're applied before the next turn." : "MCP servers changed: the backend was stopped and starts with them on your next message."; } + +/** Words for a thinking effort (#417) — the scale's, or the backend's default. */ +export const EFFORT_LABELS: Record = { + off: "Off", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra high", + max: "Max", +}; + +/** `/effort `'s argument: a level, or default / auto / reset for the backend's own. */ +export function parseEffort(arg: string): EffortLevel | null { + const a = arg.trim().toLowerCase().replace(/[\s_-]/g, ""); + if (a === "default" || a === "auto" || a === "reset") return null; + if (a === "extrahigh") return "xhigh"; + if ((EFFORT_LEVELS as readonly string[]).includes(a)) return a as EffortLevel; + throw new Error(`/effort ${EFFORT_LEVELS.join("|")}|default`); +} + +/** What `/effort` did, in words every frontend shows the same (#417). */ +export function effortChangeSummary(r: EffortChangeResult): string { + const what = r.effort ? EFFORT_LABELS[r.effort] : "the backend's default"; + if (r.applies === "now") return `Thinking effort: ${what}.`; + if (r.applies === "restart") return `Thinking effort: ${what} — the backend restarts with it before your next message.`; + return `Thinking effort: ${what}, from the next turn.`; +} diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 574d1a3e..80e7039d 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -99,6 +99,7 @@ const samples: { [T in ClientTypes]: Extract } = { "session.rotate": { type: "session.rotate", id: "r13", sessionId: "s1" }, "session.search": { type: "session.search", id: "r14", query: "auth bug", scope: "all", limit: 5 }, "session.set_model": { type: "session.set_model", id: "r15", sessionId: "s1", model: "opus", fallbackModel: null }, + "session.set_effort": { type: "session.set_effort", id: "r15e", sessionId: "s1", effort: "high" }, "session.mcp_reconnect": { type: "session.mcp_reconnect", id: "r28m", sessionId: "s1", server: "slack" }, "session.mcp_refresh": { type: "session.mcp_refresh", id: "r28r", sessionId: "s1" }, "mcp.approve.preview": { type: "mcp.approve.preview", id: "r28p", sessionId: "s1", server: "db" }, diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index ab66f163..382767f7 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -19,7 +19,7 @@ import { z } from "zod"; import type { AuthMsg, ClientMessage } from "./types.js"; -import { LIMITS } from "./types.js"; +import { EFFORT_LEVELS, LIMITS } from "./types.js"; // ── Shared field schemas ────────────────────────────────────────────────────── @@ -127,6 +127,8 @@ export const sessionCreateSchema = z.object({ * daemon can answer with a specific error, same rule as `providerId`. */ model: z.string().min(1).max(LIMITS.MODEL_MAX).optional(), + /** Thinking effort (#417). */ + effort: z.enum(EFFORT_LEVELS).optional(), /** * Activate an installed SDLC pack on this session (ambient mode — * docs/pack-loading.md): its constitution is injected into the system @@ -329,6 +331,13 @@ export const sessionSetModelSchema = z.object({ fallbackModel: z.string().max(LIMITS.MODEL_MAX).nullable().optional(), }); +export const sessionSetEffortSchema = z.object({ + ...base, + type: z.literal("session.set_effort"), + sessionId: sessionIdField, + effort: z.enum(EFFORT_LEVELS).nullable(), +}); + export const sessionForkSchema = z.object({ ...base, type: z.literal("session.fork"), @@ -1091,6 +1100,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ sessionRotateSchema, sessionSearchSchema, sessionSetModelSchema, + sessionSetEffortSchema, sessionSetProviderSchema, sessionMcpReconnectSchema, sessionMcpRefreshSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index c4b610e5..c4b8f24f 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -230,6 +230,14 @@ export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES]; * `attachments` (bounded per-file, surfaced to the model as files it can * read selectively). */ +/** + * How hard a model thinks before it answers (#417), on one scale for every + * backend; each maps it to its own setting (docs/thinking-effort.md). A + * session with no effort set sends nothing: the backend's default applies. + */ +export const EFFORT_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; +export type EffortLevel = (typeof EFFORT_LEVELS)[number]; + export const LIMITS = { /** Max `session.send.text` length. ~1M chars ≈ hundreds of thousands of tokens. */ SEND_TEXT_MAX: 1_000_000, @@ -477,6 +485,8 @@ export interface SessionInfo { model?: string; /** Fallback model id used on 429/529 capacity errors. */ fallbackModel?: string; + /** Thinking effort set on this session (#417); absent = the backend's default. */ + effort?: EffortLevel; /** * Lineage for a session created via `session.fork`. Absent = not a fork. * Frontends surface it as a chip ("⑃ forked from · turn ") @@ -1094,6 +1104,7 @@ export type ClientMessage = | SessionRotateMsg | SessionSearchMsg | SessionSetModelMsg + | SessionSetEffortMsg | SessionSetProviderMsg | SessionMcpReconnectMsg | SessionMcpRefreshMsg @@ -1318,6 +1329,8 @@ export interface SessionCreateMsg extends BaseClientMsg { * absent otherwise = the provider's default. */ model?: string; + /** Thinking effort (#417). Absent = `session.defaultEffort`, else the backend's default. */ + effort?: EffortLevel; /** * Activate an installed SDLC pack on this session (ambient mode): inject its * constitution, expose its skills/subagents, and — with `packRole` — run @@ -1817,6 +1830,28 @@ export interface SessionSetModelMsg extends BaseClientMsg { fallbackModel?: string | null; } +/** + * Set how hard the session's model thinks (#417); `null` returns it to the + * backend's default. Kept with the session, like its model. Never cuts a + * running turn short: applied live where the backend can take it (Claude, + * Qwen, pi), otherwise from the next turn — on the Gemini CLI, whose + * thinking is a startup setting, by restarting its backend, which resumes + * the conversation. Requires `session:send`. `response.ok` data is + * {@link EffortChangeResult}. + */ +export interface SessionSetEffortMsg extends BaseClientMsg { + type: "session.set_effort"; + sessionId: string; + effort: EffortLevel | null; +} + +/** `response.ok` data for `session.set_effort`. */ +export interface EffortChangeResult { + effort: EffortLevel | null; + /** When it takes effect: now, from the next turn, or once the backend restarts (before the next turn). */ + applies: "now" | "next-turn" | "restart"; +} + /** * Full-text + semantic search across ALL sessions in a workspace — the * human-facing counterpart to Claude's `recall()` tool. Searches over @@ -1993,6 +2028,14 @@ export interface ModelInfo { * the window per turn instead, and gemini, openai and acp not at all. */ contextWindow?: number; + /** + * The thinking efforts this model takes (#417), when the backend reports + * them (Claude, Codex, pi). Absent = unknown: any level may be offered, and + * the backend decides; an empty list = the model doesn't think. + */ + efforts?: EffortLevel[]; + /** The backend's own default effort for this model, when it says. */ + defaultEffort?: EffortLevel; } /** diff --git a/src/cli.ts b/src/cli.ts index 96602642..fb2548d9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -47,7 +47,8 @@ import { ALL_SCOPES_STRING } from "./protocol/scopes.js"; import { TerminalClient } from "./terminal/client.js"; import { bedrockFromSettings, formatAccounts, formatClaudeAccounts, parseHeaderFlags, parseSettingFlags } from "./terminal/claude-accounts.js"; import { formatEndpoints, importPiModels, parseModelFlags, parseTokens } from "./terminal/endpoints.js"; -import type { ClientMessage, DaemonMessage, SessionInfo } from "./protocol/types.js"; +import type { ClientMessage, DaemonMessage, EffortLevel, SessionInfo } from "./protocol/types.js"; +import { parseEffort } from "@highflame/codeoid-core"; import { mcpConfig, mcpHeaders } from "./terminal/mcp-control-cli.js"; import { cleartextOffHost, controlUrl, MCP_SCOPE_PRESETS, TokenError } from "./terminal/token.js"; import { parseTargets } from "./terminal/compare.js"; @@ -987,6 +988,10 @@ program "--model ", "Model for this session (validated against --provider). Omitted with --pack/--pack-role, the model resolves through the role's binding chain (config override → role pin → tier map).", ) + .option( + "--effort ", + "Thinking effort: off | minimal | low | medium | high | xhigh | max (default: session.defaultEffort, else the backend's own). Change it later with /effort.", + ) .option( "--pack ", "Activate an installed SDLC pack on the session (inject its constitution, expose its skills/subagents). With --collaborate, the collaboration ADOPTS the pack: role names bind strictly to the pack's declared roles.", @@ -1016,6 +1021,7 @@ program provider?: string; account?: string; model?: string; + effort?: string; pack?: string; packRole?: string; collaborate?: string; @@ -1065,11 +1071,21 @@ program process.exit(1); } + let effort: EffortLevel | undefined; + if (opts.effort) { + try { + effort = parseEffort(opts.effort) ?? undefined; + } catch (e) { + console.error(`--effort: ${e instanceof Error ? e.message : String(e)}`); + process.exit(1); + } + } const client = new TerminalClient(config); await client.connect(); await client.createSession(name, resolvedWorkdir, { providerId: opts.provider, model: opts.model, + ...(effort ? { effort } : {}), account: opts.account, pack: opts.pack, packRole: opts.packRole, diff --git a/src/config.ts b/src/config.ts index e2fced25..445ab225 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,7 @@ import { randomBytes } from "node:crypto"; import { join, isAbsolute, resolve, dirname } from "node:path"; import { homedir } from "node:os"; import { z } from "zod"; +import { EFFORT_LEVELS, type EffortLevel } from "./protocol/types.js"; import type { AuthConfig } from "./daemon/auth.js"; import type { OAuthConfig } from "./daemon/oauth.js"; import { @@ -371,6 +372,12 @@ const SessionSchema = z defaultProvider: z.string().trim().min(1).optional(), defaultModel: z.string().optional(), fallbackModel: z.string().optional(), + /** + * Thinking effort for a session created without one (#417), on codeoid's + * scale; each backend maps it (docs/thinking-effort.md). Unset = the + * backend's own default. + */ + defaultEffort: z.enum(EFFORT_LEVELS).optional(), /** * Hard backstop against a wedged turn. If the provider event stream goes * completely silent (no events at all) for this many ms while the MODEL @@ -1263,6 +1270,8 @@ export interface CodeoidConfig { defaultProvider?: string; defaultModel?: string; fallbackModel?: string; + /** Thinking effort for a new session (#417); unset = the backend's default. */ + defaultEffort?: EffortLevel; /** Stall watchdog: ms of event-stream silence while the model should be generating before a turn is force-recovered (0 = off; paused during tool execution and pending approvals). Defaults to 300000 when omitted. */ turnStallTimeoutMs?: number; /** Per-call timeout (ms) for external MCP servers, surfaced as the SDK's per-server `timeout`. 0 = use SDK default. Defaults to 120000 when omitted. */ @@ -1509,6 +1518,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ { env: "CODEOID_AUTO_ROTATE_MIN_TURNS", path: "autoRotate.minTurnsBeforeRotate", kind: "int" }, { env: DEFAULT_PROVIDER_ENV, path: "session.defaultProvider", kind: "string" }, { env: "CODEOID_DEFAULT_MODEL", path: "session.defaultModel", kind: "string" }, + { env: "CODEOID_DEFAULT_EFFORT", path: "session.defaultEffort", kind: "string" }, { env: "CODEOID_MCP_OAUTH_REDIRECT_BASE_URL", path: "mcpOAuth.redirectBaseUrl", kind: "string" }, // Dispatch kill switch — disable send-class fleet dispatch per-invocation // without touching config.json. Other dispatch knobs are file-config only, diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index fac2f3da..1357d604 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -15,7 +15,9 @@ import { spawn } from "node:child_process"; import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { atomicWrite } from "../config.js"; +import { createHash } from "node:crypto"; +import { atomicWrite, getConfigDir } from "../config.js"; +import type { EffortLevel } from "../protocol/types.js"; import { AccountError, type ResolvedAccount } from "./accounts.js"; // ── Overlay homes ───────────────────────────────────────────────────────────── @@ -265,6 +267,73 @@ export function isGeminiCredentialVar(name: string): boolean { ); } +/** + * The Gemini CLI's thinking effort (#417), which it takes only from its + * settings: a home that overlays the one it would use (an account's, or the + * default) — everything linked, logins and chats included — whose + * settings.json redefines the two aliases its chat models think through. + * One home per base and effort, kept under codeoid's account homes. + */ +export function applyGeminiCliEffort( + effort: EffortLevel | undefined, + env: Record, + homesRoot: string = join(getConfigDir(), "accounts"), +): Record { + if (!effort) return env; + const base = env.GEMINI_CLI_HOME || defaultGeminiCliRoot(); + const key = `${createHash("sha256").update(resolve(base)).digest("hex").slice(0, 12)}-${effort}`; + const home = join(homesRoot, "gemini-cli-effort", key); + mkdirSync(home, { recursive: true, mode: 0o700 }); + ensureOverlayHome({ + source: join(base, ".gemini"), + target: join(home, ".gemini"), + own: [], + copies: { "settings.json": (src) => geminiEffortSettings(src, effort) }, + shared: ["tmp"], + }); + return { ...env, GEMINI_CLI_HOME: home }; +} + +/** + * settings.json with the Gemini CLI's chat aliases set to a thinking effort: + * `chat-base-2.5` takes a token budget, `chat-base-3` a level. Each + * is redefined as the CLI ships it (extending `chat-base`) but for that value. + */ +export function geminiEffortSettings(source: string | undefined, effort: EffortLevel): string { + let settings: Record = {}; + try { + const parsed = source ? JSON.parse(source) : {}; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) settings = parsed as Record; + } catch { + // An unreadable default is unreadable for the CLI too; start clean. + } + // The overlay can't know which 2.5 model runs, so its budgets are ones + // every 2.5 model takes: Pro can't go below 128, Flash above 24576. + const budgets: Record = { off: 128, minimal: 512, low: 2048, medium: 8192, high: 16384, xhigh: 24576, max: 24576 }; + const level = effort === "off" || effort === "minimal" ? "MINIMAL" : effort === "low" ? "LOW" : effort === "medium" ? "MEDIUM" : "HIGH"; + const configs = (settings.modelConfigs && typeof settings.modelConfigs === "object" ? settings.modelConfigs : {}) as Record; + const aliases = (configs.customAliases && typeof configs.customAliases === "object" ? configs.customAliases : {}) as Record; + const thinking = (thinkingConfig: Record) => ({ + extends: "chat-base", + modelConfig: { generateContentConfig: { thinkingConfig } }, + }); + return `${JSON.stringify( + { + ...settings, + modelConfigs: { + ...configs, + customAliases: { + ...aliases, + "chat-base-2.5": thinking({ thinkingBudget: budgets[effort] }), + "chat-base-3": thinking({ thinkingLevel: level }), + }, + }, + }, + null, + 2, + )}\n`; +} + export function applyGeminiCliAccount(account: ResolvedAccount | null, env: Record): Record { if (!account) return env; ensureGeminiCliHome(account); diff --git a/src/daemon/providers/acp/index.ts b/src/daemon/providers/acp/index.ts index 6f813f90..732743b3 100644 --- a/src/daemon/providers/acp/index.ts +++ b/src/daemon/providers/acp/index.ts @@ -46,7 +46,7 @@ import type { McpServerView } from "../../mcp/registry.js"; import { McpProxyLease, type McpProxyMount } from "../../mcp/proxy.js"; import { resolveEnvMap } from "../../mcp/types.js"; import type { ResolvedAccount } from "../../accounts.js"; -import { applyGeminiCliAccount } from "../../account-apply.js"; +import { applyGeminiCliAccount, applyGeminiCliEffort } from "../../account-apply.js"; export interface GeminiAcpProviderInit { sessionId: string; @@ -98,6 +98,8 @@ export class GeminiAcpProvider implements SessionProvider { readonly appliesToolInputRewrite = false; readonly id = "gemini-cli"; readonly displayName = "Gemini CLI (Google)"; + /** Its thinking is a settings value, read at startup: a change rebuilds it (#417). */ + readonly effortChange = "restart" as const; onRecoveryNeeded: ((content: string) => void) | undefined; @@ -310,7 +312,8 @@ export class GeminiAcpProvider implements SessionProvider { args: [...this.#argsPrefix, "--acp"], cwd: opts.workdir, // The account's own GEMINI_CLI_HOME (an overlay selecting its auth type). - env: applyGeminiCliAccount(this.#resolveAccount?.() ?? null, buildGeminiCliEnv()), + // ...and, for a thinking effort, an overlay of that whose settings set it (#417). + env: applyGeminiCliEffort(opts.effort, applyGeminiCliAccount(this.#resolveAccount?.() ?? null, buildGeminiCliEnv())), onNotification: (method, params) => this.#onNotification(method, params), onServerRequest: (method, params) => this.#onServerRequest(method, params), onExit: ({ code, signal, stderrTail }) => { diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 19532803..b7d6eb9c 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -47,7 +47,7 @@ import { FLEET_TOOL_NAMES } from "../../fleet.js"; import { rewriteBashToolInput } from "../../compress/index.js"; import { type CodeoidConfig, getConfigDir } from "../../../config.js"; import { secretPathVerdict } from "../../secret-paths.js"; -import type { AuthContext } from "../../../protocol/types.js"; +import type { AuthContext, EffortLevel } from "../../../protocol/types.js"; import type { BackendMcpServerState, SessionProvider, @@ -259,6 +259,8 @@ export class ClaudeProvider implements SessionProvider { #builtOAuthMcp = ""; /** Fingerprint of the Claude account the live query loop was started with ("" = daemon env). */ #builtAccount = ""; + /** The thinking effort the live query loop runs at (#417); "" = the SDK's default. Off is fixed at construction. */ + #builtEffort = ""; /** Last TurnOpts — the retry after a skill approval rebuilds the loop with * these, then re-pushes #lastPushedContent into the SAME turn queue (#233). */ #lastTurnOpts: TurnOpts | null = null; @@ -621,6 +623,19 @@ export class ClaudeProvider implements SessionProvider { } } + /** + * A new thinking effort on the running query, live (#417) — except to or + * from Off, which the SDK fixes when the query is built: that one rebuilds + * the query before the next turn (it resumes the same conversation). + */ + async applyEffort(effort: EffortLevel | null): Promise { + const query$ = this.#query; + if (!query$ || !this.#consumerTask || effort === "off" || this.#builtEffort === "off") return false; + await query$.applyFlagSettings({ effortLevel: effort ? claudeEffort(effort) : null }); + this.#builtEffort = effort ?? ""; + return true; + } + async dispose(): Promise { await this.teardown(); } @@ -753,7 +768,8 @@ export class ClaudeProvider implements SessionProvider { this.#builtSkillAllowRules === desiredGrants && this.#builtPluginDirs === desiredPlugins && this.#builtOAuthMcp === oauthMcp.fingerprint && - this.#builtAccount === desiredAccount + this.#builtAccount === desiredAccount && + this.#builtEffort === (opts.effort ?? "") ) { return; } @@ -776,7 +792,9 @@ export class ClaudeProvider implements SessionProvider { ? "session-scoped skill plugins changed" : this.#builtOAuthMcp !== oauthMcp.fingerprint ? "OAuth MCP servers changed" - : `Claude account changed (now ${account?.name ?? "default"})`; + : this.#builtAccount !== desiredAccount + ? `Claude account changed (now ${account?.name ?? "default"})` + : `thinking effort changed (now ${opts.effort ?? "default"})`; console.log( `[claude-provider ${this.#init.sessionId.slice(0, 8)}] ${reason} — rebuilding query loop`, ); @@ -800,6 +818,7 @@ export class ClaudeProvider implements SessionProvider { this.#builtPluginDirs = desiredPlugins; this.#builtOAuthMcp = oauthMcp.fingerprint; this.#builtAccount = desiredAccount; + this.#builtEffort = opts.effort ?? ""; this.#loopGeneration += 1; const myGeneration = this.#loopGeneration; @@ -871,7 +890,9 @@ export class ClaudeProvider implements SessionProvider { // way — the tokens were already spent, we just weren't being shown // anything for them. (The raw chain of thought is never exposed on any // model; this is a summary of it.) - thinking: { type: "adaptive" as const, display: "summarized" as const }, + // Thinking effort (#417): Off disables thinking; any other level rides + // on adaptive thinking. + ...claudeEffortOptions(opts.effort), // An account may name models its own way (a Bedrock account's mappings). ...modelOptions(opts.model, opts.fallbackModel, account), ...(opts.maxBudgetUsd !== undefined ? { maxBudgetUsd: opts.maxBudgetUsd } : {}), @@ -1082,7 +1103,7 @@ export class ClaudeProvider implements SessionProvider { if (init.onModels && account?.kind !== "bedrock") { void this.#query .supportedModels() - .then((m) => init.onModels?.(m)) + .then((m) => init.onModels?.(m.map(claudeCatalogEntry))) .catch(() => {}); } @@ -2212,3 +2233,41 @@ export function extractToolResultText(content: unknown): string { return parts.join("\n"); } + +/** codeoid's effort scale on the Agent SDK's (#417): it has no Minimal, so that is Low. */ +function claudeEffort(effort: Exclude): "low" | "medium" | "high" | "xhigh" | "max" { + return effort === "minimal" ? "low" : effort; +} + +/** + * The query options for a thinking effort (#417). Thinking stays adaptive, + * with readable summaries (see the comment where it's used); Off disables it. + */ +export function claudeEffortOptions(effort: EffortLevel | undefined): { + thinking: { type: "adaptive"; display: "summarized" } | { type: "disabled" }; + effort?: "low" | "medium" | "high" | "xhigh" | "max"; +} { + if (effort === "off") return { thinking: { type: "disabled" } }; + const thinking = { type: "adaptive" as const, display: "summarized" as const }; + return effort ? { thinking, effort: claudeEffort(effort) } : { thinking }; +} + +/** One of the SDK's model rows as a catalog entry, with the efforts the model takes (#417). */ +export function claudeCatalogEntry(m: { + value: string; + displayName: string; + description?: string; + supportsEffort?: boolean; + supportedEffortLevels?: ("low" | "medium" | "high" | "xhigh" | "max")[]; +}): CatalogEntry { + // Off (thinking disabled) is always there; the levels are the model's own. + // A model that doesn't say which levels it takes leaves them unknown. + const efforts: EffortLevel[] | undefined = + m.supportsEffort === false ? ["off"] : m.supportedEffortLevels ? ["off", ...m.supportedEffortLevels] : undefined; + return { + value: m.value, + displayName: m.displayName, + ...(m.description ? { description: m.description } : {}), + ...(efforts ? { efforts } : {}), + }; +} diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index e9478153..c9c598aa 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -67,7 +67,7 @@ import { renderHistorySeed, type CanonicalTurn, type HistorySeedResult } from ". import { type ResponsesItem, toResponsesItems } from "./native-history.js"; import { buildCodexEnv } from "../env.js"; import { CodexRpcProcess } from "./rpc.js"; -import type { SessionMode } from "../../../protocol/types.js"; +import { EFFORT_LEVELS, type EffortLevel, type SessionMode } from "../../../protocol/types.js"; import { MEMORY_MCP_SERVER_NAME, MEMORY_MCP_TOKEN_ENV, type MemoryMcpMount } from "../../memory/mcp-http.js"; import { BLACKBOARD_MCP_SERVER_NAME, @@ -644,15 +644,31 @@ export class CodexProvider implements SessionProvider { if (!this.#proc?.alive) return []; try { const result = (await this.#proc.request("model/list", {})) as { - data?: Array<{ id?: string; model?: string; displayName?: string; description?: string }>; + data?: Array<{ + id?: string; + model?: string; + displayName?: string; + description?: string; + supportedReasoningEfforts?: Array<{ reasoningEffort?: string }>; + defaultReasoningEffort?: string; + }>; }; return (result.data ?? []) .filter((m) => typeof (m.model ?? m.id) === "string") - .map((m) => ({ - id: (m.model ?? m.id) as string, - displayName: m.displayName ?? ((m.model ?? m.id) as string), - description: m.description, - })); + .map((m) => { + // The efforts it takes (#417), on codeoid's scale — codex's "none" is Off. + const efforts = Array.isArray(m.supportedReasoningEfforts) + ? m.supportedReasoningEfforts.map((e) => fromCodexEffort(e?.reasoningEffort)).filter((e): e is EffortLevel => e !== null) + : undefined; + const defaultEffort = fromCodexEffort(m.defaultReasoningEffort); + return { + id: (m.model ?? m.id) as string, + displayName: m.displayName ?? ((m.model ?? m.id) as string), + description: m.description, + ...(efforts ? { efforts } : {}), + ...(defaultEffort ? { defaultEffort } : {}), + }; + }); } catch { return []; } @@ -697,6 +713,9 @@ export class CodexProvider implements SessionProvider { approvalPolicy, sandboxPolicy: sandboxPolicyWire(sandboxMode, opts.workdir), ...(opts.model ? { model: opts.model } : {}), + // Thinking effort (#417). codex keeps a turn's effort for the turns + // after it (a resumed thread too), so none is sent as null: its default. + effort: opts.effort ? toCodexEffort(opts.effort) : null, })) as { turn?: { id?: string } }; this.#currentTurnId = result.turn?.id ?? null; } @@ -816,7 +835,15 @@ export class CodexProvider implements SessionProvider { const models = await this.listModels(); if (models.length > 0) { this.#modelsReported = true; - this.#onModels(models.map((m) => ({ value: m.id, displayName: m.displayName, description: m.description }))); + this.#onModels( + models.map((m) => ({ + value: m.id, + displayName: m.displayName, + description: m.description, + ...(m.efforts ? { efforts: m.efforts } : {}), + ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), + })), + ); } } @@ -1353,3 +1380,14 @@ function extractItemOutput(item: Record): string { const out = item.aggregatedOutput ?? item.output ?? item.result ?? item.text ?? ""; return typeof out === "string" ? out : JSON.stringify(out); } + +/** codeoid's effort scale in codex's terms (#417): Off is its "none"; the rest share names. */ +export function toCodexEffort(effort: EffortLevel): string { + return effort === "off" ? "none" : effort; +} + +/** A codex reasoning effort on codeoid's scale, or null for one codeoid has no level for. */ +export function fromCodexEffort(effort: string | undefined): EffortLevel | null { + if (effort === "none") return "off"; + return effort && (EFFORT_LEVELS as readonly string[]).includes(effort) ? (effort as EffortLevel) : null; +} diff --git a/src/daemon/providers/effort.ts b/src/daemon/providers/effort.ts new file mode 100644 index 00000000..e92f14c0 --- /dev/null +++ b/src/daemon/providers/effort.ts @@ -0,0 +1,13 @@ +/** + * Thinking effort (#417), what the backends share: each maps codeoid's scale + * (EFFORT_LEVELS) to its own setting in its provider; docs/thinking-effort.md. + */ + +import type { EffortLevel } from "../../protocol/types.js"; + +/** When a server refuses a turn over its thinking effort, say which setting to change. */ +export function effortRejectionHint(message: string, effort: EffortLevel): string { + return /reasoning|effort|thinking/i.test(message) + ? ` — this model may not take thinking effort "${effort}": choose another, or Default (/effort default).` + : ""; +} diff --git a/src/daemon/providers/gemini/index.ts b/src/daemon/providers/gemini/index.ts index 50ede9af..efecb4ad 100644 --- a/src/daemon/providers/gemini/index.ts +++ b/src/daemon/providers/gemini/index.ts @@ -16,10 +16,13 @@ */ import type { McpTenant } from "../../mcp/oauth.js"; +import type { EffortLevel } from "../../../protocol/types.js"; +import { effortRejectionHint } from "../effort.js"; import { GoogleGenerativeAI, type FunctionDeclaration, type GenerateContentStreamResult, + type GenerationConfig, type Part, } from "@google/generative-ai"; import { AsyncQueue } from "../../async-queue.js"; @@ -183,6 +186,11 @@ export class GeminiProvider implements AgentProvider { ...(declarations.length > 0 ? { tools: [{ functionDeclarations: declarations as unknown as FunctionDeclaration[] }] } : {}), + // Thinking effort (#417). This SDK predates `thinkingConfig` in its + // types but sends generationConfig as given; the API reads it. + ...(opts.effort && geminiThinkingConfig(model, opts.effort) + ? { generationConfig: { thinkingConfig: geminiThinkingConfig(model, opts.effort) } as GenerationConfig } + : {}), }); try { @@ -216,7 +224,7 @@ export class GeminiProvider implements AgentProvider { if (ac.signal.aborted) return; queue.push({ type: "error", - message: `Gemini API error: ${err instanceof Error ? err.message : String(err)}`, + message: `Gemini API error: ${err instanceof Error ? err.message : String(err)}${opts.effort ? effortRejectionHint(err instanceof Error ? err.message : String(err), opts.effort) : ""}`, }); return; } @@ -336,3 +344,23 @@ export function splitForStateless(opts: TurnOpts): { userMessage: opts.userMessage, }; } + +/** + * A thinking effort as Gemini's thinkingConfig (#417), by model family: + * 2.5 models take a token budget (0 = off; Pro can't turn thinking off, so + * its floor is 128; Flash's ceiling is 24576), 3.x models a level. Other + * models don't think: nothing is sent. + */ +export function geminiThinkingConfig(model: string, effort: EffortLevel): Record | null { + if (/gemini-2\.5/.test(model)) { + const pro = /pro/.test(model); + const budgets: Record = { off: 0, minimal: 512, low: 2048, medium: 8192, high: 16384, xhigh: 24576, max: 32768 }; + const budget = Math.min(Math.max(budgets[effort], pro ? 128 : 0), pro ? 32768 : 24576); + return { thinkingBudget: budget }; + } + if (/gemini-3/.test(model)) { + const level = effort === "off" || effort === "minimal" ? "minimal" : effort === "low" ? "low" : effort === "medium" ? "medium" : "high"; + return { thinkingLevel: level }; + } + return null; +} diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index 3ffd7949..ea370971 100644 --- a/src/daemon/providers/interface.ts +++ b/src/daemon/providers/interface.ts @@ -13,6 +13,7 @@ import type { AuthContext, ContentPart, + EffortLevel, ProviderCommand, SessionMode, UiRequestMethod, @@ -113,6 +114,8 @@ export interface TurnOpts { userMessage: string; model?: string; fallbackModel?: string; + /** Thinking effort for this turn (#417), on codeoid's scale; absent = the backend's default. */ + effort?: EffortLevel; workdir: string; systemPromptAppend?: string; /** Session's unified approval gate — same semantics across all providers. */ @@ -505,6 +508,9 @@ export interface ModelInfo { /** Context window in tokens, when the backend publishes it on its catalog. * qwen-code and pi do; Claude and codex report the window per turn. */ contextWindow?: number; + /** Thinking efforts the model takes, when the backend reports them (#417). */ + efforts?: EffortLevel[]; + defaultEffort?: EffortLevel; } /** @@ -521,6 +527,10 @@ export interface CatalogEntry { displayName: string; description?: string; contextWindow?: number; + /** Thinking efforts the model takes (#417), when the backend reports them; [] = it doesn't think. */ + efforts?: EffortLevel[]; + /** The backend's default effort for the model, when it says. */ + defaultEffort?: EffortLevel; } /** @@ -597,6 +607,19 @@ export interface AgentProvider { * reconnect: that's a result with `status` and `error`. */ reconnectMcpServer?(name: string): Promise; + /** + * Thinking effort (#417): how a change reaches this backend. Absent or + * "turn" = it takes each turn's `TurnOpts.effort`; "restart" = only when the + * backend starts (the Gemini CLI's settings), so the session rebuilds it + * before the next turn. + */ + readonly effortChange?: "turn" | "restart"; + /** + * Apply a new effort to the running backend now, where it can (Claude, + * Qwen, pi) — never cutting a turn short. True when it did; false (or + * absent) = it applies from the next turn's options. + */ + applyEffort?(effort: EffortLevel | null): Promise; /** * Hand this (fresh) backend an existing conversation in its OWN native form, * instead of a transcript in the first prompt (#387): fork its own diff --git a/src/daemon/providers/llamacpp/index.ts b/src/daemon/providers/llamacpp/index.ts index 83ab2836..f1400589 100644 --- a/src/daemon/providers/llamacpp/index.ts +++ b/src/daemon/providers/llamacpp/index.ts @@ -18,6 +18,7 @@ * instead of a cloud-sized table entry that would overflow the server. */ +import type { EffortLevel } from "../../../protocol/types.js"; import { OpenAIProvider, type OpenAIProviderInit } from "../openai/index.js"; import type { OpenAIFunctionTool } from "../tool-loop.js"; import type { McpServerSource } from "../../mcp/tool-source.js"; @@ -149,7 +150,7 @@ export function createLlamaCppProvider( cfg: LlamaCppConfig, init: Omit< OpenAIProviderInit, - "id" | "displayName" | "apiKey" | "baseURL" | "modelFilter" | "fallbackModels" | "contextWindow" | "toolsTransform" + "id" | "displayName" | "apiKey" | "baseURL" | "modelFilter" | "fallbackModels" | "contextWindow" | "toolsTransform" | "effortParams" >, ): OpenAIProvider { return new OpenAIProvider({ @@ -164,5 +165,19 @@ export function createLlamaCppProvider( fallbackModels: [], contextWindow: () => fetchServerContextWindow(cfg.baseUrl), toolsTransform: llamaCppTools, + effortParams: llamaCppEffortParams, }); } + +/** + * A thinking effort for llama-server (#417), through the model's chat + * template: `enable_thinking` (Qwen3-style templates) and `reasoning_effort` + * (gpt-oss-style, low / medium / high — the scale is clamped to those). A + * template that reads neither ignores them; the server ignores fields it + * doesn't know. + */ +export function llamaCppEffortParams(effort: EffortLevel): Record { + if (effort === "off") return { chat_template_kwargs: { enable_thinking: false } }; + const level = effort === "minimal" || effort === "low" ? "low" : effort === "medium" ? "medium" : "high"; + return { chat_template_kwargs: { enable_thinking: true, reasoning_effort: level }, reasoning_effort: level }; +} diff --git a/src/daemon/providers/openai/index.ts b/src/daemon/providers/openai/index.ts index 28e86ed2..0d56e123 100644 --- a/src/daemon/providers/openai/index.ts +++ b/src/daemon/providers/openai/index.ts @@ -15,6 +15,8 @@ */ import type { McpTenant } from "../../mcp/oauth.js"; +import type { EffortLevel } from "../../../protocol/types.js"; +import { effortRejectionHint } from "../effort.js"; import OpenAI from "openai"; import { AsyncQueue } from "../../async-queue.js"; import type { @@ -129,6 +131,11 @@ export interface OpenAIProviderInit { * OpenAI's about tool schemas (llama.cpp compiles them into a grammar). */ toolsTransform?: (tools: OpenAIFunctionTool[]) => OpenAIFunctionTool[]; + /** + * The request fields for a thinking effort (#417). Default: OpenAI's + * `reasoning_effort`. A server that names it otherwise (llama.cpp) says how. + */ + effortParams?: (effort: EffortLevel) => Record; } const OPENAI_CHAT_MODEL = (id: string) => @@ -178,6 +185,7 @@ export class OpenAIProvider implements AgentProvider { #fallbackModels: ModelInfo[]; #contextWindow: ((model: string) => Promise) | null; #toolsTransform: ((tools: OpenAIFunctionTool[]) => OpenAIFunctionTool[]) | null; + #effortParams: (effort: EffortLevel) => Record; #resolveApiKey: (() => { key: string; fingerprint: string } | null) | null; constructor(init: OpenAIProviderInit = {}) { @@ -205,6 +213,7 @@ export class OpenAIProvider implements AgentProvider { this.#fallbackModels = init.fallbackModels ?? OPENAI_FALLBACK_MODELS; this.#contextWindow = init.contextWindow ?? null; this.#toolsTransform = init.toolsTransform ?? null; + this.#effortParams = init.effortParams ?? openAIEffortParams; this.#resolveApiKey = init.resolveApiKey ?? null; } @@ -364,6 +373,8 @@ export class OpenAIProvider implements AgentProvider { ...(streamUsage ? { stream_options: { include_usage: true } } : {}), ...(maxTokens ? { [maxTokensField]: maxTokens } : {}), ...(offerTools ? { tools } : {}), + // Thinking effort (#417): a model that doesn't reason rejects it, and the turn says so. + ...(opts.effort ? this.#effortParams(opts.effort) : {}), }, { signal: ac.signal }, ); @@ -478,9 +489,10 @@ export class OpenAIProvider implements AgentProvider { queue.push({ type: "turn_done", result }); } catch (err) { if (!ac.signal.aborted) { + const text = isRedirectRefusal(err) ? `the server ${REDIRECT_HINT}` : err instanceof Error ? err.message : String(err); queue.push({ type: "error", - message: `${this.displayName}: ${isRedirectRefusal(err) ? `the server ${REDIRECT_HINT}` : err instanceof Error ? err.message : String(err)}`, + message: `${this.displayName}: ${text}${opts.effort ? effortRejectionHint(text, opts.effort) : ""}`, }); } } finally { @@ -505,3 +517,8 @@ export class OpenAIProvider implements AgentProvider { // Stateless — nothing to tear down. } } + +/** OpenAI's reasoning effort for codeoid's scale (#417): Off is "none"; it has nothing above XHigh, so Max is XHigh. */ +export function openAIEffortParams(effort: EffortLevel): Record { + return { reasoning_effort: effort === "off" ? "none" : effort === "max" ? "xhigh" : effort }; +} diff --git a/src/daemon/providers/pi/index.ts b/src/daemon/providers/pi/index.ts index 8444b243..bae8c942 100644 --- a/src/daemon/providers/pi/index.ts +++ b/src/daemon/providers/pi/index.ts @@ -34,7 +34,7 @@ import type { TurnRun, UiRequestFn, } from "../interface.js"; -import type { ProviderCommand } from "../../../protocol/types.js"; +import { EFFORT_LEVELS, type EffortLevel, type ProviderCommand } from "../../../protocol/types.js"; import { renderHistorySeed, type CanonicalTurn, type HistorySeedResult } from "../canonical.js"; import { buildPiEnv } from "../env.js"; import { @@ -123,6 +123,9 @@ export class PiProvider implements SessionProvider { #bridgeReady = false; #hasQueried = false; #model: string | null = null; + /** pi's thinking level now, and the one it started with — "default" returns to it (#417). */ + #thinkingLevel: string | null = null; + #startThinkingLevel: string | null = null; /** Rendered transcript from seedFromHistory() — prepended to the next prompt. */ #pendingHistorySeed: string | null = null; @@ -293,6 +296,9 @@ export class PiProvider implements SessionProvider { if (opts.model && opts.model !== this.#model) { await this.#setModel(opts.model); } + // Thinking effort (#417): pi's levels are codeoid's; none = what pi started with. + const level = opts.effort ?? this.#startThinkingLevel; + if (level && level !== this.#thinkingLevel) await this.#setThinkingLevel(level); let message = opts.userMessage; if (this.#pendingHistorySeed) { @@ -315,6 +321,7 @@ export class PiProvider implements SessionProvider { } this.#bridgeReady = false; + this.#thinkingLevel = null; // a new pi reports its own (get_state, below) this.#proc = new PiRpcProcess({ command: this.#command, argsPrefix: this.#argsPrefix, @@ -353,7 +360,11 @@ export class PiProvider implements SessionProvider { } const state = await this.#proc.request({ type: "get_state" }); - const data = state.data as { sessionFile?: string; sessionId?: string } | undefined; + const data = state.data as { sessionFile?: string; sessionId?: string; thinkingLevel?: string } | undefined; + if (typeof data?.thinkingLevel === "string") { + this.#startThinkingLevel ??= data.thinkingLevel; + this.#thinkingLevel ??= data.thinkingLevel; + } const file = data?.sessionFile; if (typeof file === "string" && file.length > 0 && file !== this.#backingSessionId) { this.#backingSessionId = file; @@ -396,7 +407,7 @@ export class PiProvider implements SessionProvider { if (!Array.isArray(data?.models)) return []; return data.models .filter( - (m): m is { id: string; provider: string; name?: string; contextWindow?: unknown } => + (m): m is { id: string; provider: string; name?: string; contextWindow?: unknown; reasoning?: unknown; thinkingLevelMap?: unknown } => !!m && typeof m === "object" && typeof (m as { id?: unknown }).id === "string" && @@ -407,9 +418,24 @@ export class PiProvider implements SessionProvider { displayName: typeof m.name === "string" ? m.name : m.id, // pi's catalog entries are full pi-ai Model objects, window included. ...(typeof m.contextWindow === "number" && m.contextWindow > 0 ? { contextWindow: m.contextWindow } : {}), + efforts: piThinkingLevels(m), })); } + /** A new thinking effort on the running pi, live (#417): it takes the next request at that level. */ + async applyEffort(effort: EffortLevel | null): Promise { + const level = effort ?? this.#startThinkingLevel; + if (!this.#proc || !level) return false; + if (level !== this.#thinkingLevel) await this.#setThinkingLevel(level); + return true; + } + + async #setThinkingLevel(level: string): Promise { + const resp = await this.#proc!.request({ type: "set_thinking_level", level }); + if (resp.success === false) throw new Error(`pi could not set thinking level "${level}": ${String(resp.error ?? "")}`); + this.#thinkingLevel = level; + } + async #setModel(model: string): Promise { // codeoid model values for pi are "provider/modelId" (as reported via // onModels). Anything else is passed through as an anthropic id so @@ -777,3 +803,19 @@ export class PiProvider implements SessionProvider { } } } + +/** + * The thinking levels a pi model takes (#417), as pi-ai's + * getSupportedThinkingLevels decides them: Off alone for a model that doesn't + * reason; otherwise every level but one its map marks unsupported (null), and + * XHigh / Max only where the map names them. + */ +export function piThinkingLevels(m: { reasoning?: unknown; thinkingLevelMap?: unknown }): EffortLevel[] { + if (m.reasoning !== true) return ["off"]; + const map = (m.thinkingLevelMap && typeof m.thinkingLevelMap === "object" ? m.thinkingLevelMap : {}) as Record; + return EFFORT_LEVELS.filter((level) => { + const mapped = map[level]; + if (mapped === null) return false; + return level === "xhigh" || level === "max" ? mapped !== undefined : true; + }); +} diff --git a/src/daemon/providers/qwen/index.ts b/src/daemon/providers/qwen/index.ts index 3cf29f6f..2c1ecc60 100644 --- a/src/daemon/providers/qwen/index.ts +++ b/src/daemon/providers/qwen/index.ts @@ -43,7 +43,7 @@ import { McpProxyLease, type McpProxyMount } from "../../mcp/proxy.js"; import { resolveEnvMap } from "../../mcp/types.js"; import { FLEET_TOOL_NAMES } from "../../fleet.js"; import { resolveQwenBaseUrl, type CodeoidConfig } from "../../../config.js"; -import type { AuthContext } from "../../../protocol/types.js"; +import type { AuthContext, EffortLevel } from "../../../protocol/types.js"; import type { ModelInfo, NormalizedTurnResult, @@ -123,6 +123,8 @@ export class QwenProvider implements SessionProvider { #builtSystemPromptAppend = ""; /** Fingerprint of the account the running query was built with ("" = the daemon's own). */ #builtAccount = ""; + /** The thinking effort the live query runs at (#417); "" = the SDK's default. */ + #builtEffort = ""; /** Bumped on every (re)build so an orphaned consumer can't clobber the new loop. */ #loopGeneration = 0; @@ -298,6 +300,14 @@ export class QwenProvider implements SessionProvider { } } + /** A new thinking effort on the running query, live (#417); back to the default needs a fresh query (next turn). */ + async applyEffort(effort: EffortLevel | null): Promise { + if (!this.#query || !this.#consumerTask || effort === null) return false; + const applied = await this.#query.setEffort(qwenEffort(effort)); + if (applied) this.#builtEffort = effort; + return applied; + } + async dispose(): Promise { await this.teardown(); } @@ -370,10 +380,11 @@ export class QwenProvider implements SessionProvider { // The SDK fixes the system prompt at query construction, so a changed // append (before_turn hooks, refreshed memory index) needs a rebuild. // The fresh query RESUMES the same backing session, so nothing is lost. - if (this.#builtSystemPromptAppend === desiredAppend && this.#builtAccount === desiredAccount) return; - console.log( - `[qwen-provider ${this.#init.sessionId.slice(0, 8)}] ${this.#builtAccount === desiredAccount ? "systemPromptAppend" : "account"} changed — rebuilding query loop`, - ); + if (this.#builtSystemPromptAppend === desiredAppend && this.#builtAccount === desiredAccount && this.#builtEffort === (opts.effort ?? "")) return; + // A changed effort that setEffort couldn't take (back to the default) rebuilds too (#417). + const what = + this.#builtSystemPromptAppend !== desiredAppend ? "systemPromptAppend" : this.#builtAccount !== desiredAccount ? "account" : "thinking effort"; + console.log(`[qwen-provider ${this.#init.sessionId.slice(0, 8)}] ${what} changed — rebuilding query loop`); this.#inputQueue.close(); this.#abortController?.abort(); } @@ -384,6 +395,7 @@ export class QwenProvider implements SessionProvider { this.#inputQueue = new AsyncQueue(); this.#builtSystemPromptAppend = desiredAppend; this.#builtAccount = desiredAccount; + this.#builtEffort = opts.effort ?? ""; this.#loopGeneration += 1; const myGeneration = this.#loopGeneration; @@ -447,6 +459,8 @@ export class QwenProvider implements SessionProvider { // canUseTool; qwen-code's own `auto` mode would silently auto-approve // edits and shell commands, bypassing the gate entirely. permissionMode: "default", + // Thinking effort (#417); the SDK clamps it to what the model takes. + ...(opts.effort ? { effort: qwenEffort(opts.effort) } : {}), includePartialMessages: true, allowedTools: [ ...(init.memory ? MEMORY_TOOL_NAMES.map((t) => `mcp__codeoid_memory__${t}`) : []), @@ -1098,3 +1112,8 @@ export function extractToolResultText(content: unknown): string { } return parts.join("\n"); } + +/** codeoid's effort scale on qwen-code's (#417): it has no Off or Minimal — both are its lowest, Low. */ +export function qwenEffort(effort: EffortLevel): "low" | "medium" | "high" | "xhigh" | "max" { + return effort === "off" || effort === "minimal" ? "low" : effort; +} diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 24b575a4..ad7b7636 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -1235,6 +1235,8 @@ mcpHub: this.#mcpHub, return this.#search(msg, auth); case "session.set_model": return this.#setModel(msg, auth); + case "session.set_effort": + return this.#setEffort(msg, auth); case "session.set_provider": return this.#setProvider(msg, auth); case "session.mcp_reconnect": @@ -1810,6 +1812,8 @@ mcpHub: this.#mcpHub, displayName: m.displayName, ...(m.description ? { description: m.description } : {}), ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), + ...(m.efforts ? { efforts: m.efforts } : {}), + ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), isDefault: m.value === "default", })); const previous = this.#modelsCache.get(providerId); @@ -4030,6 +4034,7 @@ mcpHub: this.#mcpHub, // Explicit `model`, or the pack role's resolved binding (§6.2). Outranks // config.session.defaultModel; still loses to a persisted choice. defaultModel, + ...(msg.effort ? { effort: msg.effort } : {}), pack, collaboration, identityManager: this.#identityManager, @@ -7598,6 +7603,17 @@ mcpHub: this.#mcpHub, * model). Returns `response.ok` with the resolved model in `data` on * success; rejects with a 400 when the model id is unknown. */ + /** Set the session's thinking effort (#417). `session:send` on the caller's own session, as for its model. */ + async #setEffort(msg: Extract, auth: AuthContext): Promise { + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_SEND)) { + return { type: "response.error", requestId: msg.id, error: "Missing scope: session:send", code: "forbidden" }; + } + const session = this.#getOwnedSession(msg.sessionId, auth); + if (!session) return { type: "response.error", requestId: msg.id, error: "Session not found", code: "not_found" }; + const data = await session.setEffort(msg.effort, auth); + return { type: "response.ok", requestId: msg.id, data }; + } + async #setModel( msg: Extract, auth: AuthContext, diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 19f40a1e..23af6ae7 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -85,7 +85,7 @@ import { restoreTree, } from "./checkpoints.js"; import { applyRewinds, REWIND_EVENT, type TurnIndexEntry } from "./transcript.js"; -import type { RewindFiles, RewindIrreversible, TurnSummary } from "../protocol/types.js"; +import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, TurnSummary } from "../protocol/types.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; import type { PendingToolApproval, SessionApproveOutcome } from "../protocol/types.js"; import { execFile } from "node:child_process"; @@ -420,6 +420,11 @@ export interface SessionCreateOptions { * conductor's config.conductor.model override. */ defaultModel?: string; + /** + * Thinking effort chosen at creation (#417): outranks + * config.session.defaultEffort, and is kept with the session. + */ + effort?: EffortLevel; /** * Observe every status transition of this session. The dispatcher uses * this to detect a worker's turn completing (→ idle/error) or wedging @@ -909,6 +914,10 @@ export class Session { // effect on the NEXT send() (current stream is torn down on change). #model: string | null = null; #fallbackModel: string | null = null; + /** Thinking effort (#417), on codeoid's scale; null = the backend's default. Kept across a backend switch. */ + #effort: EffortLevel | null = null; + /** A backend whose effort is a startup setting (the Gemini CLI) is rebuilt before the next turn. */ + #effortRestartPending = false; // ── Auto-rotation (Layer D) ──────────────────────────────────────────── // Claude Code's backing session id — distinct from codeoid's public @@ -1298,6 +1307,10 @@ export class Session { effectiveProviderId, ) ?? null; + // Thinking effort (#417): a resumed session keeps its own (none = the + // backend's default); a new one takes the effort chosen at creation, else + // the config default. + this.#effort = opts.existingId ? this.#store.getSessionEffort(this.id) : (opts.effort ?? opts.config?.session.defaultEffort ?? null); this.#provider = opts._testProvider ?? this.#createProvider(opts.providerId); this.#appliedAccount = this.#accountState(this.#provider.id); @@ -1365,6 +1378,7 @@ export class Session { projectId: opts.auth.projectId, }); this.#store.audit(opts.auth.sub, "session.create", this.id, `name=${this.name}`); + if (this.#effort) this.#store.setSessionEffort(this.id, this.#effort); this.#transcriptStore.saveMeta({ sessionId: this.id, @@ -3223,6 +3237,7 @@ export class Session { history: this.#accumulator.history, userMessage: content, model: this.#model ?? undefined, + effort: this.#effort ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#buildPromptAppend(), @@ -3258,6 +3273,8 @@ export class Session { // asked for meanwhile applied (#395) — before the stop and adopted-turn // checks below, which must come after every await. await this.#applyMcpView(); + // A thinking effort the backend takes only at startup (#417): rebuild it now, between turns. + await this.#applyEffortRestart(); // Redial any backend MCP server that's down, so this turn can use a // server fixed since it failed (#389) — bounded, and backed off. await this.#healMcpServers(); @@ -3311,6 +3328,7 @@ export class Session { history: this.#accumulator.history, userMessage: turnPrompt, model: this.#model ?? undefined, + effort: this.#effort ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#composePromptAppend(hookPromptAppend), @@ -4294,6 +4312,7 @@ export class Session { history: this.#accumulator.history, userMessage: prompt, model: this.#model ?? undefined, + effort: this.#effort ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#composePromptAppend(), @@ -4662,6 +4681,7 @@ export class Session { queuedMessages: this.#provider.queuedMessages, model: this.#model ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, + effort: this.#effort ?? undefined, forkedFrom: this.forkedFrom, worktree: this.worktree, collaboration: this.collaboration, @@ -5038,6 +5058,55 @@ export class Session { return { model: resolved, fallbackModel: this.#fallbackModel }; } + /** + * Set how hard the model thinks (#417); null = the backend's default. Kept + * with the session. Never cuts a running turn short: applied live where + * the backend can take it, otherwise from the next turn's options — or, on + * a backend that takes it only at startup, by rebuilding it before the next + * turn (it resumes the conversation). + */ + async setEffort(effort: EffortLevel | null, sender: AuthContext): Promise { + const prev = this.#effort; + this.#effort = effort; + this.#store.setSessionEffort(this.id, effort); + this.#store.audit(sender.sub, "session.set_effort", this.id, `effort=${effort ?? "(default)"} from=${prev ?? "(default)"}`); + let applies: EffortChangeResult["applies"] = "next-turn"; + if (effort === prev) { + applies = "now"; + } else if (this.#provider.effortChange === "restart") { + this.#effortRestartPending = true; + applies = "restart"; + } else if (this.#provider.applyEffort) { + try { + if (await this.#provider.applyEffort(effort)) applies = "now"; + } catch (err) { + console.error(`[codeoid/session ${this.id}] applying thinking effort live failed (it applies from the next turn): ${err instanceof Error ? err.message : String(err)}`); + } + } + const shown = effort ?? "the backend's default"; + const when = applies === "now" ? "" : applies === "restart" ? " — the backend restarts with it before the next turn" : " — from the next turn"; + const info = this.#makeMessage("info", `⎆ Thinking effort: ${shown}${when}.`, SYSTEM_IDENTITY, undefined, undefined, { + event: "session.effort_changed", + effort, + prev_effort: prev, + applies, + }); + this.#persistAndBuffer(info); + this.#broadcastRaw(info); + this.#broadcastInfoUpdate(); + return { effort, applies }; + } + + /** Rebuild a backend that takes its effort only at startup, between turns (#417). */ + async #applyEffortRestart(): Promise { + if (!this.#effortRestartPending || this.#mcpBusy()) return; + this.#effortRestartPending = false; + await this.#teardownProvider(); + if (RESEED_AFTER_TEARDOWN.has(this.#provider.id) && this.#accumulator.history.length > 0) { + await this.#seedProviderFromHistory(); + } + } + /** * Phase-scoped model override for pipeline runs (docs/role-model-binding.md * §3): the run host sets the phase's bound model before the phase turn and diff --git a/src/daemon/store.ts b/src/daemon/store.ts index ac3b41a1..3db97ed8 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -6,7 +6,7 @@ import { Database } from "bun:sqlite"; import { chmodSync, existsSync } from "node:fs"; -import type { ModelInfo, PushPlatform, SessionInfo, SessionStatus } from "../protocol/types.js"; +import { EFFORT_LEVELS, type EffortLevel, type ModelInfo, type PushPlatform, type SessionInfo, type SessionStatus } from "../protocol/types.js"; // ── Dispatch queue types (P4) ───────────────────────────────────────────── @@ -285,6 +285,8 @@ export class Store { // Model selection — persisted so /model choice survives daemon restart. this.#addColumnIfMissing("sessions", "model", "TEXT"); this.#addColumnIfMissing("sessions", "fallback_model", "TEXT"); + // Thinking effort (#417), kept like the model. NULL = the backend's default. + this.#addColumnIfMissing("sessions", "effort", "TEXT"); // Session role ("conductor") + backing provider id — persisted so the // conductor keeps its role and every session keeps its provider across // daemon restarts. NULL = normal session / claude (pre-upgrade rows). @@ -872,6 +874,18 @@ export class Store { .run(backingId, rotationDelta, rotatedAt ?? null, id); } + /** The session's persisted thinking effort (#417); null = never set (the backend's default). */ + getSessionEffort(id: string): EffortLevel | null { + const row = this.#db.prepare("SELECT effort AS e FROM sessions WHERE id = ?").get(id) as { e: string | null } | undefined; + const e = row?.e ?? null; + return e !== null && (EFFORT_LEVELS as readonly string[]).includes(e) ? (e as EffortLevel) : null; + } + + /** Persist the session's thinking effort; null clears it. */ + setSessionEffort(id: string, effort: EffortLevel | null): void { + this.#db.prepare(`UPDATE sessions SET effort = ?, updated_at = datetime('now') WHERE id = ?`).run(effort, id); + } + /** * Get the persisted model selection for a session. Either field may be * null if never set — caller falls back to config defaults. diff --git a/src/frontends/telegram/index.ts b/src/frontends/telegram/index.ts index a1b84b5b..30b3cab5 100644 --- a/src/frontends/telegram/index.ts +++ b/src/frontends/telegram/index.ts @@ -19,7 +19,7 @@ import { Bot, type Context, InlineKeyboard } from "grammy"; import { autoRetry } from "@grammyjs/auto-retry"; import { randomUUID } from "node:crypto"; -import { formatCollaborationCost, mcpRefreshSummary } from "@highflame/codeoid-core"; +import { EFFORT_LABELS, effortChangeSummary, formatCollaborationCost, mcpRefreshSummary, parseEffort } from "@highflame/codeoid-core"; import { getManifest, getSnapshot } from "../../daemon/settings/store.js"; import { ALL_SCOPES_STRING } from "../../protocol/scopes.js"; import type { Frontend, FrontendContext } from "../types.js"; @@ -39,7 +39,7 @@ import type { ToolInfo, UiRequestMethod, } from "../../protocol/types.js"; -import { CAPABILITIES } from "../../protocol/types.js"; +import { CAPABILITIES, EFFORT_LEVELS, type EffortChangeResult, type EffortLevel } from "../../protocol/types.js"; import type { AttachedClient } from "../../daemon/session.js"; import { StreamRelay, @@ -211,6 +211,7 @@ export class TelegramFrontend implements Frontend { { command: "interrupt", description: "Stop the current turn" }, { command: "mode", description: "Set mode: interactive | guarded | autonomous" }, { command: "model", description: "Show or switch the model" }, + { command: "effort", description: "How hard the model thinks: off … max, or default" }, { command: "provider", description: "Show or switch the backend: /provider " }, { command: "fork", description: "Branch this session: /fork [backend]" }, { command: "rotate", description: "Fresh context (memory kept)" }, @@ -272,6 +273,7 @@ export class TelegramFrontend implements Frontend { this.#bot.command("rotate", (ctx) => this.#handleRotate(ctx)); this.#bot.command("mode", (ctx) => this.#handleMode(ctx)); this.#bot.command("model", (ctx) => this.#handleModel(ctx)); + this.#bot.command("effort", (ctx) => this.#handleEffort(ctx)); this.#bot.command("provider", (ctx) => this.#handleProvider(ctx)); this.#bot.command("fork", (ctx) => this.#handleFork(ctx)); this.#bot.command("who", (ctx) => this.#handleWho(ctx)); @@ -799,6 +801,34 @@ export class TelegramFrontend implements Frontend { await ctx.reply(`Mode → *${escMd(mode)}*\\.`, { parse_mode: "MarkdownV2" }); } + /** `/effort [level]` — show or set the attached session's thinking effort (#417). */ + async #handleEffort(ctx: Context): Promise { + const state = this.#requireAuth(ctx); + if (!state || !state.attachedSessionId) { + await ctx.reply("Not attached. Use /attach ."); + return; + } + const arg = ctx.message?.text?.split(/\s+/)[1]; + if (!arg) { + const current = state.attachedSessionName ? this.#manager.findByName(state.attachedSessionName, state.auth!)?.toInfo().effort : undefined; + await ctx.reply(`Thinking effort: ${current ? EFFORT_LABELS[current] : "the backend's default"}.\nSet it with /effort ${EFFORT_LEVELS.join("|")}|default`); + return; + } + let effort: EffortLevel | null; + try { + effort = parseEffort(arg); + } catch (e) { + await ctx.reply(`Usage: ${e instanceof Error ? e.message : String(e)}`); + return; + } + const resp = await this.#manager.handle( + { type: "session.set_effort", id: randomUUID(), sessionId: state.attachedSessionId, effort }, + state.auth!, + this.#makeClient(state, ctx), + ); + await ctx.reply(resp.type === "response.ok" ? effortChangeSummary(resp.data as EffortChangeResult) : resp.type === "response.error" ? resp.error : "Could not set the effort."); + } + async #handleModel(ctx: Context): Promise { const state = this.#requireAuth(ctx); if (!state || !state.attachedSessionId) { diff --git a/src/terminal/client.ts b/src/terminal/client.ts index c9b087bf..6c45ae21 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -12,6 +12,7 @@ import { PIPELINE_INPUT_REQUEST_PREFIX, PROTOCOL_VERSION } from "../protocol/typ import type { ClientMessage, CollaborationConfig, + EffortLevel, McpReconnectResult, McpApprovePreview, McpRefreshResult, @@ -438,6 +439,7 @@ export class TerminalClient { claudeAccount?: string; /** Account for the backend the session lands on (accounts.ts). */ account?: string; + effort?: EffortLevel; collaboration?: CollaborationConfig; } = {}, ): Promise { @@ -451,6 +453,7 @@ export class TerminalClient { // a claude session. ...(opts.providerId ? { providerId: opts.providerId } : {}), ...(opts.model ? { model: opts.model } : {}), + ...(opts.effort ? { effort: opts.effort } : {}), ...(opts.claudeAccount ? { claudeAccount: opts.claudeAccount } : {}), ...(opts.account ? { account: opts.account } : {}), ...(opts.pack ? { pack: opts.pack } : {}), diff --git a/src/tests/fixtures/fake-codex.ts b/src/tests/fixtures/fake-codex.ts index d9300dd3..7efc2251 100644 --- a/src/tests/fixtures/fake-codex.ts +++ b/src/tests/fixtures/fake-codex.ts @@ -37,6 +37,8 @@ const pendingServerReqs = new Map) => v * the "echo-policy" prompt so the provider's wire params can be asserted. */ let lastThreadPolicies: Record = {}; let lastTurnPolicies: Record = {}; +/** turn/start's `effort` (#417), echoed by "echo-effort". */ +let lastTurnEffort: unknown = "(absent)"; /** thread/fork and thread/inject_items calls, in order — echoed by "echo-native" (#387). */ const nativeCalls: Array<{ method: string; params: Record }> = []; @@ -310,6 +312,8 @@ async function runTurn(threadId: string, prompt: string): Promise { const resp = await serverRequest("custom/unknownThing", { anything: true }); const text = resp.__error ? "server-request-errored" : "server-request-oddly-ok"; send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text } } }); + } else if (prompt.includes("echo-effort")) { + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: JSON.stringify({ effort: lastTurnEffort }) } } }); } else if (prompt.includes("echo-policy")) { const text = JSON.stringify({ thread: lastThreadPolicies, turn: lastTurnPolicies }); send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text } } }); @@ -421,7 +425,14 @@ async function handle(frame: Record): Promise { id, result: { data: [ - { id: "gpt-5.6-terra", model: "gpt-5.6-terra", displayName: "GPT-5.6-Terra", description: "Balanced agentic coding model." }, + { + id: "gpt-5.6-terra", + model: "gpt-5.6-terra", + displayName: "GPT-5.6-Terra", + description: "Balanced agentic coding model.", + supportedReasoningEfforts: [{ reasoningEffort: "none" }, { reasoningEffort: "low" }, { reasoningEffort: "high" }, { reasoningEffort: "ultra" }], + defaultReasoningEffort: "low", + }, ], }, }); @@ -430,6 +441,7 @@ async function handle(frame: Record): Promise { const bad = sandboxPolicyError(params.sandboxPolicy); if (bad) return void send({ id, error: { code: -32602, message: bad } }); lastTurnPolicies = { approvalPolicy: params.approvalPolicy, sandboxPolicy: params.sandboxPolicy }; + lastTurnEffort = "effort" in params ? params.effort : "(absent)"; send({ id, result: { turn: { id: "turn-1" } } }); const input = params.input as Array<{ type: string; text?: string }> | undefined; const prompt = input?.find((i) => i.type === "text")?.text ?? ""; diff --git a/src/tests/fixtures/fake-pi.ts b/src/tests/fixtures/fake-pi.ts index 5c5c57be..91cf15a7 100644 --- a/src/tests/fixtures/fake-pi.ts +++ b/src/tests/fixtures/fake-pi.ts @@ -24,6 +24,8 @@ const sessionId = `fake-${process.pid}`; const sessionFile = `/tmp/fake-pi-${process.pid}.jsonl`; +/** pi's thinking level (#417): "medium" at start, as pi's own default. */ +let thinkingLevel = "medium"; let turns = 0; const pendingUi = new Map) => void>(); @@ -147,6 +149,8 @@ async function runPrompt(message: string): Promise { result: { content: [{ type: "text", text: "wrote file" }] }, isError: false, }); + } else if (message.includes("echo-thinking")) { + emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: `thinking:${thinkingLevel}` }], stopReason: "stop" } }); } else if (message.includes("echo-prompt")) { // Reflect the FULL received prompt back — lets tests prove what // actually reached pi (e.g. the prepended history seed). @@ -216,7 +220,7 @@ function handle(cmd: Record): void { sessionId, sessionFile, isStreaming: false, - thinkingLevel: "off", + thinkingLevel, messageCount: turns, }, }); @@ -238,8 +242,8 @@ function handle(cmd: Record): void { respond(cmd.id, "get_available_models", { data: { models: [ - { id: "claude-sonnet-4-5", provider: "anthropic", name: "Claude Sonnet 4.5", contextWindow: 200_000 }, - { id: "gpt-5", provider: "openai", name: "GPT-5", contextWindow: 400_000 }, + { id: "claude-sonnet-4-5", provider: "anthropic", name: "Claude Sonnet 4.5", contextWindow: 200_000, reasoning: true }, + { id: "gpt-5", provider: "openai", name: "GPT-5", contextWindow: 400_000, reasoning: true, thinkingLevelMap: { minimal: null, xhigh: "xhigh" } }, ], }, }); @@ -257,6 +261,10 @@ function handle(cmd: Record): void { case "set_model": respond(cmd.id, "set_model", { data: {} }); return; + case "set_thinking_level": + thinkingLevel = String(cmd.level); + respond(cmd.id, "set_thinking_level"); + return; case "switch_session": case "new_session": respond(cmd.id, String(cmd.type), { data: { cancelled: false } }); diff --git a/src/tests/provider-claude.test.ts b/src/tests/provider-claude.test.ts index 5479d0a3..f2ffeadb 100644 --- a/src/tests/provider-claude.test.ts +++ b/src/tests/provider-claude.test.ts @@ -38,6 +38,8 @@ let sdkInterruptThrows = false; let mcpStatuses: Array> = []; let mcpReconnects: string[] = []; let mcpReconnectError: Error | null = null; +/** Live settings the mock query was handed (#417). */ +let flagSettings: Array> = []; function makeMockQuery() { const err = sdkThrowError; @@ -64,6 +66,9 @@ function makeMockQuery() { if (mcpReconnectError) throw mcpReconnectError; }, mcpServerStatus: async () => mcpStatuses, + applyFlagSettings: async (settings: Record) => { + flagSettings.push(settings); + }, }; } @@ -88,6 +93,8 @@ mock.module("@anthropic-ai/claude-agent-sdk", () => ({ import { ClaudeProvider, + claudeCatalogEntry, + claudeEffortOptions, translateSDKMessage, parseMcpServerConfig, extractToolResultText, @@ -110,6 +117,7 @@ import { applyAccountEnv, bedrockModel, type ResolvedClaudeAccount, type StoredC import { SqliteEpisodeStore } from "../daemon/memory/store.js"; import { MEMORY_TOOL_NAMES } from "../daemon/memory/index.js"; import type { Embedder } from "../daemon/memory/embedder.js"; +import type { EffortLevel } from "../protocol/types.js"; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -2620,3 +2628,77 @@ describe("ClaudeProvider — adopting a turn the CLI starts on its own", () => { expect(got.map((e) => e.type)).toEqual(["background_event", "turn_started"]); }); }); + +// ── Thinking effort (#417) ──────────────────────────────────────────────────── + +describe("thinking effort", () => { + beforeEach(() => { + sdkMessages = []; + sdkThrowError = null; + capturedQueryOpts = null; + sdkGate = null; + flagSettings = []; + }); + const turn = (provider: ClaudeProvider, effort?: EffortLevel) => + provider.runTurn({ history: [], userMessage: "hi", workdir: "/tmp", canUseTool: async () => ({ behavior: "allow" as const }), ...(effort ? { effort } : {}) }); + const options = () => (capturedQueryOpts?.options ?? {}) as { effort?: string; thinking?: unknown }; + + it("rides on adaptive thinking at every level, and Off disables thinking", async () => { + sdkMessages = [{ type: "result", modelUsage: {} }]; + for await (const _ of turn(makeProvider(), "xhigh").events) { + } + expect(options()).toMatchObject({ effort: "xhigh", thinking: { type: "adaptive", display: "summarized" } }); + for await (const _ of turn(makeProvider(), "off").events) { + } + expect(options().thinking).toEqual({ type: "disabled" }); + expect(options().effort).toBeUndefined(); + for await (const _ of turn(makeProvider()).events) { + } + expect(options().effort).toBeUndefined(); // the default: nothing sent + expect(options().thinking).toEqual({ type: "adaptive", display: "summarized" }); + }); + + it("changes live on a running query — but to or from Off rebuilds it before the next turn", async () => { + sdkMessages = [{ type: "result", modelUsage: {} }]; + let release!: () => void; + sdkGate = new Promise((r) => { + release = r; + }); + const provider = makeProvider(); + queryCallCount = 0; + const first = turn(provider, "high"); + for await (const e of first.events) if (e.type === "turn_done") break; + expect(await provider.applyEffort("max")).toBe(true); + expect(flagSettings).toEqual([{ effortLevel: "max" }]); + expect(await provider.applyEffort(null)).toBe(true); // back to the model's default + expect(flagSettings.at(-1)).toEqual({ effortLevel: null }); + expect(await provider.applyEffort("off")).toBe(false); // fixed at construction + // An effort the live query already runs at doesn't rebuild it... + void turn(provider); + for (let i = 0; i < 20; i++) await Promise.resolve(); + expect(queryCallCount).toBe(1); + // ...Off does: a fresh query, with thinking disabled. + const off = turn(provider, "off"); + for (let i = 0; i < 50 && queryCallCount < 2; i++) await Bun.sleep(2); + expect(queryCallCount).toBe(2); + expect(options().thinking).toEqual({ type: "disabled" }); + release(); + for await (const _ of off.events) { + } + await provider.teardown(); + }); + + it("reports which levels each model takes", () => { + expect(claudeCatalogEntry({ value: "opus", displayName: "Opus", supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"] }).efforts).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(claudeCatalogEntry({ value: "haiku", displayName: "Haiku", supportsEffort: false }).efforts).toEqual(["off"]); + expect(claudeCatalogEntry({ value: "x", displayName: "X" }).efforts).toBeUndefined(); // not said: unknown + expect(claudeEffortOptions("minimal")).toMatchObject({ effort: "low" }); // no Minimal on Claude + }); +}); diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts index 4dc875de..46a691bd 100644 --- a/src/tests/provider-codex.test.ts +++ b/src/tests/provider-codex.test.ts @@ -42,6 +42,7 @@ import { createDefaultProviderRegistry } from "../daemon/providers/registry.js"; import type { ProviderEvent, TurnOpts, TurnRun } from "../daemon/providers/interface.js"; import type { CodeoidConfig } from "../config.js"; import type { Store } from "../daemon/store.js"; +import type { EffortLevel } from "../protocol/types.js"; const FIXTURE = join(import.meta.dir, "fixtures", "fake-codex.ts"); @@ -668,7 +669,14 @@ describe("CodexProvider over fake-codex", () => { const models = await p.listModels(); await p.teardown(); expect(models).toEqual([ - { id: "gpt-5.6-terra", displayName: "GPT-5.6-Terra", description: "Balanced agentic coding model." }, + { + id: "gpt-5.6-terra", + displayName: "GPT-5.6-Terra", + description: "Balanced agentic coding model.", + // Its efforts on codeoid's scale (#417): "none" is Off; "ultra" has no level. + efforts: ["off", "low", "high"], + defaultEffort: "low", + }, ]); }); @@ -1287,3 +1295,26 @@ describe("describeCodexError", () => { } }); }); + +describe("CodexProvider – thinking effort (#417)", () => { + const effortSeen = async (effort?: EffortLevel): Promise => { + const p = makeProvider(); + const events = await collect(p.runTurn(turnOpts("echo-effort", effort ? { effort } : {}))); + await p.teardown(); + return JSON.parse((events.find((e) => e.type === "text_done") as { content: string }).content).effort; + }; + + it("sends it on every turn — Off as codex's none, and none set as null (codex keeps a turn's effort)", async () => { + expect(await effortSeen("high")).toBe("high"); + expect(await effortSeen("off")).toBe("none"); + expect(await effortSeen()).toBeNull(); + }); + + it("reports each model's efforts on codeoid's scale, dropping ones it has no level for", async () => { + const p = makeProvider(); + await collect(p.runTurn(turnOpts("hello"))); + const models = await p.listModels(); + await p.teardown(); + expect(models[0]).toMatchObject({ efforts: ["off", "low", "high"], defaultEffort: "low" }); + }); +}); diff --git a/src/tests/provider-openai.test.ts b/src/tests/provider-openai.test.ts index cc5b4e95..688f5713 100644 --- a/src/tests/provider-openai.test.ts +++ b/src/tests/provider-openai.test.ts @@ -7,6 +7,7 @@ import { mock, describe, it, expect, beforeEach } from "bun:test"; import type { ProviderEvent, TurnOpts, UiRequest } from "../daemon/providers/interface.js"; +import type { EffortLevel } from "../protocol/types.js"; import * as realOpenAIModule from "openai"; /** @@ -137,6 +138,20 @@ describe("OpenAIProvider – streaming", () => { sentBodies.length = 0; }); + it("sends the thinking effort as reasoning_effort, on OpenAI's scale (#417)", async () => { + const done = [{ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }]; + const effortOf = async (effort?: EffortLevel) => { + streamChunks = [...done]; + sentBodies.length = 0; + await runProvider(new OpenAIProvider({ apiKey: "k" }), "hi", effort ? { effort } : {}); + return sentBodies[0]?.reasoning_effort; + }; + expect(await effortOf("high")).toBe("high"); + expect(await effortOf("off")).toBe("none"); + expect(await effortOf("max")).toBe("xhigh"); // nothing above XHigh + expect(await effortOf()).toBeUndefined(); // the default: nothing sent + }); + it("emits text_delta + text_done + turn_done for a normal response", async () => { streamChunks = [ { choices: [{ delta: { content: "Hello " } }] }, diff --git a/src/tests/provider-pi.test.ts b/src/tests/provider-pi.test.ts index 0afc8d78..8875e620 100644 --- a/src/tests/provider-pi.test.ts +++ b/src/tests/provider-pi.test.ts @@ -10,7 +10,7 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Store } from "../daemon/store.js"; -import { PiProvider } from "../daemon/providers/pi/index.js"; +import { PiProvider, piThinkingLevels } from "../daemon/providers/pi/index.js"; import type { ProviderEvent, ToolApprovalFn, @@ -411,3 +411,22 @@ describe("PiProvider – memory tools (#178 Phase 4)", () => { expect(text).toContain('MCP:echo:{"msg":"hi"}'); }); }); + +describe("PiProvider – thinking effort (#417)", () => { + const said = (events: ProviderEvent[]) => (events.find((e) => e.type === "text_done") as { content: string } | undefined)?.content; + + it("sets pi's thinking level per turn, live between turns, and back to pi's own for the default", async () => { + const p = makeProvider(); + expect(said(await collect(p.runTurn(turnOpts("echo-thinking", { effort: "high" })).events))).toBe("thinking:high"); + expect(await p.applyEffort("low")).toBe(true); + expect(said(await collect(p.runTurn(turnOpts("echo-thinking", { effort: "low" })).events))).toBe("thinking:low"); + expect(said(await collect(p.runTurn(turnOpts("echo-thinking")).events))).toBe("thinking:medium"); // what pi started with + await p.teardown(); + }); + + it("reports which levels each model takes, as pi-ai decides them", () => { + expect(piThinkingLevels({ reasoning: false })).toEqual(["off"]); + expect(piThinkingLevels({ reasoning: true })).toEqual(["off", "minimal", "low", "medium", "high"]); + expect(piThinkingLevels({ reasoning: true, thinkingLevelMap: { minimal: null, xhigh: "x", max: "m" } })).toEqual(["off", "low", "medium", "high", "xhigh", "max"]); + }); +}); diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts new file mode 100644 index 00000000..91498e2c --- /dev/null +++ b/src/tests/thinking-effort.test.ts @@ -0,0 +1,264 @@ +/** + * Thinking effort (#417): one scale for every backend, set per session — + * kept like its model, applied live where the backend can take it, from the + * next turn otherwise, by a rebuild before the next turn where it's a + * startup setting (the Gemini CLI) — and each backend's own reading of it. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { Database } from "bun:sqlite"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseClientMessage } from "@highflame/codeoid-protocol/schemas"; +import type { CodeoidConfig } from "../config.js"; +import { applyGeminiCliEffort, geminiEffortSettings } from "../daemon/account-apply.js"; +import { geminiThinkingConfig } from "../daemon/providers/gemini/index.js"; +import { llamaCppEffortParams } from "../daemon/providers/llamacpp/index.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { qwenEffort } from "../daemon/providers/qwen/index.js"; +import { ProviderRegistry } from "../daemon/providers/registry.js"; +import { Session } from "../daemon/session.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, EffortChangeResult, EffortLevel, SessionInfo } from "../protocol/types.js"; + +let tmp: string; +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-effort-")); +}); +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("each backend's reading of the scale", () => { + it("Qwen has no Off or Minimal: both are its lowest", () => { + expect([qwenEffort("off"), qwenEffort("minimal"), qwenEffort("medium"), qwenEffort("max")]).toEqual(["low", "low", "medium", "max"]); + }); + + it("llama.cpp: through the chat template, clamped to low / medium / high", () => { + expect(llamaCppEffortParams("off")).toEqual({ chat_template_kwargs: { enable_thinking: false } }); + expect(llamaCppEffortParams("max")).toEqual({ chat_template_kwargs: { enable_thinking: true, reasoning_effort: "high" }, reasoning_effort: "high" }); + expect(llamaCppEffortParams("minimal")).toMatchObject({ reasoning_effort: "low" }); + }); + + it("Gemini: a budget for 2.5 (Pro can't go below 128, Flash above 24576), a level for 3.x, nothing for others", () => { + expect(geminiThinkingConfig("gemini-2.5-flash", "off")).toEqual({ thinkingBudget: 0 }); + expect(geminiThinkingConfig("gemini-2.5-pro", "off")).toEqual({ thinkingBudget: 128 }); + expect(geminiThinkingConfig("gemini-2.5-flash", "max")).toEqual({ thinkingBudget: 24576 }); + expect(geminiThinkingConfig("gemini-2.5-pro", "max")).toEqual({ thinkingBudget: 32768 }); + expect(geminiThinkingConfig("gemini-3-pro-preview", "xhigh")).toEqual({ thinkingLevel: "high" }); + expect(geminiThinkingConfig("gemini-3-flash", "off")).toEqual({ thinkingLevel: "minimal" }); + expect(geminiThinkingConfig("gemini-1.5-pro", "high")).toBeNull(); + }); + + it("the Gemini CLI: its chat aliases redefined in settings, everything else kept", () => { + const out = JSON.parse(geminiEffortSettings(JSON.stringify({ theme: "dark", modelConfigs: { customAliases: { mine: { extends: "base" } } } }), "low")); + expect(out.theme).toBe("dark"); + expect(out.modelConfigs.customAliases.mine).toEqual({ extends: "base" }); + expect(out.modelConfigs.customAliases["chat-base-2.5"]).toEqual({ + extends: "chat-base", + modelConfig: { generateContentConfig: { thinkingConfig: { thinkingBudget: 2048 } } }, + }); + expect(out.modelConfigs.customAliases["chat-base-3"].modelConfig.generateContentConfig.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); + // Values every 2.5 model takes: the settings can't know which runs. + const off = JSON.parse(geminiEffortSettings(undefined, "off")); + expect(off.modelConfigs.customAliases["chat-base-2.5"].modelConfig.generateContentConfig.thinkingConfig.thinkingBudget).toBe(128); + expect(JSON.parse(geminiEffortSettings("not json", "max")).modelConfigs.customAliases["chat-base-2.5"].modelConfig.generateContentConfig.thinkingConfig.thinkingBudget).toBe(24576); + }); + + it("the Gemini CLI runs from an overlay of its usual home — logins and chats linked, settings its own", () => { + const base = join(tmp, "base"); + mkdirSync(join(base, ".gemini", "tmp"), { recursive: true }); + writeFileSync(join(base, ".gemini", "oauth_creds.json"), "{}"); + writeFileSync(join(base, ".gemini", "settings.json"), JSON.stringify({ theme: "dark" })); + const homes = join(tmp, "homes"); + const env = applyGeminiCliEffort("high", { GEMINI_CLI_HOME: base, PATH: "/bin" }, homes); + const home = env.GEMINI_CLI_HOME!; + expect(home.startsWith(join(homes, "gemini-cli-effort"))).toBe(true); + expect(env.PATH).toBe("/bin"); + expect(readlinkSync(join(home, ".gemini", "oauth_creds.json"))).toBe(join(base, ".gemini", "oauth_creds.json")); + expect(lstatSync(join(home, ".gemini", "tmp")).isSymbolicLink()).toBe(true); + const settings = JSON.parse(readFileSync(join(home, ".gemini", "settings.json"), "utf8")); + expect(settings.theme).toBe("dark"); + expect(settings.modelConfigs.customAliases["chat-base-3"].modelConfig.generateContentConfig.thinkingConfig.thinkingLevel).toBe("HIGH"); + // One home per effort; no effort, no overlay. + expect(applyGeminiCliEffort("low", { GEMINI_CLI_HOME: base }, homes).GEMINI_CLI_HOME).not.toBe(home); + expect(applyGeminiCliEffort(undefined, { GEMINI_CLI_HOME: base }, homes)).toEqual({ GEMINI_CLI_HOME: base }); + }); +}); + +// ── through the session manager ────────────────────────────────────────────── + +const ALL = [SCOPES.SESSION_CREATE, SCOPES.SESSION_SEND, SCOPES.SESSION_LIST]; +const auth = (scopes: string[] = ALL, accountId = "acc-1"): AuthContext => ({ + sub: "user:a", + scopes: scopes as AuthContext["scopes"], + delegationDepth: 0, + accountId, + projectId: "proj-1", +}); +const client = { id: "c1", auth: auth(), send: () => {} }; + +/** A backend that takes a new effort live. */ +class LiveProvider extends MockSessionProvider { + readonly applied: Array = []; + async applyEffort(effort: EffortLevel | null): Promise { + this.applied.push(effort); + return true; + } +} +/** A backend that takes its effort only at startup (as the Gemini CLI). */ +class RestartProvider extends MockSessionProvider { + readonly effortChange = "restart" as const; +} + +function manager(make: () => MockSessionProvider, providerId = "claude", defaultEffort?: EffortLevel): { m: SessionManager; store: Store; providers: MockSessionProvider[] } { + const store = new Store(join(tmp, "codeoid.db")); + const providers: MockSessionProvider[] = []; + const registry = new ProviderRegistry(providerId); + registry.register({ + id: providerId, + displayName: providerId, + create: () => { + const p = make(); + providers.push(p); + return p; + }, + }); + const config = { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: defaultEffort ? { defaultEffort } : {}, + conductor: { enabled: false, name: "conductor", provider: "claude" }, + } as CodeoidConfig; + const m = new SessionManager(store, new TranscriptStore(join(tmp, "t")), undefined, undefined, undefined, { config, providers: registry }); + return { m, store, providers }; +} + +async function create(m: SessionManager, effort?: EffortLevel): Promise { + const work = join(tmp, "work"); + mkdirSync(work, { recursive: true }); + const r = (await m.handle({ type: "session.create", id: "c", name: "w", workdir: work, ...(effort ? { effort } : {}) }, auth(), client)) as { + type: string; + data: { id: string }; + }; + expect(r.type).toBe("response.ok"); + return r.data.id; +} + +const setEffort = async (m: SessionManager, sessionId: string, effort: EffortLevel | null, a = auth()) => + (await m.handle({ type: "session.set_effort", id: "e", sessionId, effort }, a, client)) as { type: string; data?: EffortChangeResult; code?: string }; + +async function turn(m: SessionManager, sid: string, text = "go"): Promise { + const s = m._sessionForTest(sid)!; + await s.send(text, auth()); + for (let i = 0; i < 200 && s.status !== "idle"; i++) await Bun.sleep(5); + await Bun.sleep(10); +} + +describe("a session's thinking effort", () => { + it("is set at creation, kept with the session, and handed to the backend on every turn", async () => { + const { m, store, providers } = manager(() => new MockSessionProvider("claude", [])); + const sid = await create(m, "high"); + expect((m._sessionForTest(sid)!.toInfo() as SessionInfo).effort).toBe("high"); + expect(store.getSessionEffort(sid)).toBe("high"); + await turn(m, sid); + expect(providers[0]!.capturedOpts.at(-1)?.effort).toBe("high"); + }); + + it("changes from the next turn on a backend that takes it per turn — on the record, and shown to clients", async () => { + const { m, store, providers } = manager(() => new MockSessionProvider("claude", [])); + const sid = await create(m); + await turn(m, sid); + expect(providers[0]!.capturedOpts.at(-1)?.effort).toBeUndefined(); // the backend's default + expect(await setEffort(m, sid, "low")).toMatchObject({ type: "response.ok", data: { effort: "low", applies: "next-turn" } }); + expect(m._sessionForTest(sid)!.toInfo().effort).toBe("low"); + await turn(m, sid); + expect(providers[0]!.capturedOpts.at(-1)?.effort).toBe("low"); + expect(await setEffort(m, sid, null)).toMatchObject({ data: { effort: null } }); + expect(store.getSessionEffort(sid)).toBeNull(); + await turn(m, sid); + expect(providers[0]!.capturedOpts.at(-1)?.effort).toBeUndefined(); + store.close(); + const db = new Database(join(tmp, "codeoid.db"), { readonly: true }); + const rows = db.query("SELECT detail FROM audit_log WHERE action = 'session.set_effort' ORDER BY id").all() as Array<{ detail: string }>; + db.close(); + expect(rows.map((r) => r.detail)).toEqual(["effort=low from=(default)", "effort=(default) from=low"]); + }); + + it("applies live where the backend can take it", async () => { + const { m, providers } = manager(() => new LiveProvider("claude", [])); + const sid = await create(m); + await turn(m, sid); + expect(await setEffort(m, sid, "max")).toMatchObject({ data: { applies: "now" } }); + expect((providers[0] as LiveProvider).applied).toEqual(["max"]); + }); + + it("rebuilds a backend that takes it only at startup — before the next turn, never under a running one", async () => { + const { m, providers } = manager(() => new RestartProvider("gemini-cli", []), "gemini-cli"); + const sid = await create(m); + await turn(m, sid); + expect(await setEffort(m, sid, "high")).toMatchObject({ data: { applies: "restart" } }); + expect(providers[0]!.teardownCount).toBe(0); // not at once + await turn(m, sid); + expect(providers[0]!.teardownCount).toBe(1); // before the turn + expect(providers[0]!.capturedOpts.at(-1)?.effort).toBe("high"); + await turn(m, sid); + expect(providers[0]!.teardownCount).toBe(1); // once + }); + + it("is the session owner's to change: session:send, their own session", async () => { + const { m } = manager(() => new MockSessionProvider("claude", [])); + const sid = await create(m); + expect(await setEffort(m, sid, "high", auth([SCOPES.SESSION_LIST]))).toMatchObject({ code: "forbidden" }); + expect(await setEffort(m, sid, "high", auth(ALL, "acc-2"))).toMatchObject({ code: "not_found" }); + expect(parseClientMessage({ type: "session.set_effort", id: "e", sessionId: sid, effort: "turbo" }).ok).toBe(false); + }); + + it("a new session takes the config default; a resumed one keeps its own", async () => { + const { m, store } = manager(() => new MockSessionProvider("claude", []), "claude", "medium"); + const sid = await create(m); + expect(store.getSessionEffort(sid)).toBe("medium"); + expect(m._sessionForTest(sid)!.toInfo().effort).toBe("medium"); + // Resumed: the persisted one — none set means the backend's default, not the config's. + store.setSessionEffort(sid, null); + const registry = new ProviderRegistry("claude"); + registry.register({ id: "claude", displayName: "claude", create: () => new MockSessionProvider("claude", []) }); + const resumed = new Session({ + name: "w", + workdir: join(tmp, "work"), + auth: auth(), + store, + transcriptStore: new TranscriptStore(join(tmp, "t")), + existingId: sid, + providers: registry, + config: { session: { defaultEffort: "max" } } as CodeoidConfig, + }); + expect(resumed.toInfo().effort).toBeUndefined(); + store.setSessionEffort(sid, "xhigh"); + const again = new Session({ name: "w", workdir: join(tmp, "work"), auth: auth(), store, transcriptStore: new TranscriptStore(join(tmp, "t")), existingId: sid, providers: registry }); + expect(again.toInfo().effort).toBe("xhigh"); + }); + + it("the model list carries the levels each model takes", async () => { + const { m } = manager(() => new MockSessionProvider("claude", [])); + (m as unknown as { _cacheModels: (p: string, r: unknown[]) => void })._cacheModels("claude", [ + { value: "opus", displayName: "Opus", efforts: ["off", "low", "high"], defaultEffort: "high" }, + { value: "other", displayName: "Other" }, + ]); + const r = (await m.handle({ type: "models.list", id: "m", provider: "claude" }, auth(), client)) as { models: Array<{ value: string; efforts?: string[]; defaultEffort?: string }> }; + expect(r.models.find((x) => x.value === "opus")).toMatchObject({ efforts: ["off", "low", "high"], defaultEffort: "high" }); + expect(r.models.find((x) => x.value === "other")?.efforts).toBeUndefined(); + }); +}); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index d5acebd7..2c56c99e 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -19,9 +19,9 @@ import { loadWorkspaceCommands, type WorkspaceCommand, } from "./workspace-commands.js"; -import type { Attachment, McpReconnectResult, McpRefreshResult } from "../protocol/types.js"; +import type { Attachment, EffortChangeResult, EffortLevel, McpReconnectResult, McpRefreshResult } from "../protocol/types.js"; import { backendHasAccounts } from "../protocol/types.js"; -import { mcpRefreshSummary } from "@highflame/codeoid-core"; +import { effortChangeSummary, mcpRefreshSummary, parseEffort } from "@highflame/codeoid-core"; import type { TuiAction } from "./types.js"; import { parseDialogAnswer } from "../terminal/dialog.js"; import { formatRewind, parseForkArgs, parseUndoArgs } from "../terminal/rewind.js"; @@ -1109,6 +1109,27 @@ export function App({ config, createClient = (c, d) => new TuiWsClient(c, d) }: }); return; } + case "/effort": { + // Thinking effort (#417): bare opens the picker, else a level or "default". + if (!focusedSession || !client) { + dispatch({ type: "error", message: !focusedSession ? "No session focused" : "Not connected" }); + return; + } + const sessionId = focusedSession.info.id; + if (args.length === 0) { + dispatch({ type: "modal.open", modal: { kind: "effort", sessionId } }); + return; + } + let effort: EffortLevel | null; + try { + effort = parseEffort(args[0]!); + } catch (e) { + dispatch({ type: "error", message: `Usage: ${e instanceof Error ? e.message : String(e)}` }); + return; + } + void onSetEffort(sessionId, effort).catch((cause: unknown) => dispatch({ type: "error", message: cause instanceof Error ? cause.message : String(cause) })); + return; + } case "/model": case "/provider": case "/account": { @@ -1251,6 +1272,14 @@ export function App({ config, createClient = (c, d) => new TuiWsClient(c, d) }: } }; + const onSetEffort = async (sessionId: string, effort: EffortLevel | null): Promise => { + const client = wsRef.current; + if (!client) throw new Error("Not connected"); + const resp = await client.setEffort(sessionId, effort); + if (resp.type === "response.error") throw new Error(resp.error); + if (resp.type === "response.ok") printLocalInfo(sessionId, effortChangeSummary(resp.data as EffortChangeResult)); + }; + const onSetProvider = async (sessionId: string, providerId: string): Promise => { const client = wsRef.current; if (!client) throw new Error("Not connected"); @@ -1354,6 +1383,7 @@ export function App({ config, createClient = (c, d) => new TuiWsClient(c, d) }: onCancel={onCancelModal} onSearch={onSearch} onSetModel={onSetModel} + onSetEffort={onSetEffort} onSetProvider={onSetProvider} onSetAccount={onSetAccount} /> diff --git a/src/tui/components/BackendPicker.tsx b/src/tui/components/BackendPicker.tsx index 8af34284..24567843 100644 --- a/src/tui/components/BackendPicker.tsx +++ b/src/tui/components/BackendPicker.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; -import type { ProviderInfo } from "../../protocol/types.js"; +import { EFFORT_LEVELS, type EffortLevel, type ProviderInfo } from "../../protocol/types.js"; +import { EFFORT_LABELS } from "@highflame/codeoid-core"; import type { TuiWsClient } from "../ws.js"; import { accountChoices } from "../accounts.js"; @@ -11,8 +12,10 @@ export interface BackendChoice { } interface Props { - kind: "model" | "provider" | "account"; + kind: "model" | "provider" | "account" | "effort"; provider: string; + /** For the effort picker: the session's model, whose levels are offered. */ + model?: string; /** Name of the session this picker changes, shown in the header. */ sessionName?: string; current: string; @@ -22,7 +25,7 @@ interface Props { onCancel: () => void; } -export function BackendPicker({ kind, provider, sessionName, current, providers, client, onPick, onCancel }: Props) { +export function BackendPicker({ kind, provider, model, sessionName, current, providers, client, onPick, onCancel }: Props) { const [choices, setChoices] = useState([]); const [loading, setLoading] = useState(kind !== "provider"); const [busy, setBusy] = useState(false); @@ -43,7 +46,14 @@ export function BackendPicker({ kind, provider, sessionName, current, providers, setError(null); const load = async () => { if (!client) throw new Error("Not connected"); - if (kind === "model") { + if (kind === "effort") { + // The levels the model takes, when its backend says (#417); otherwise the whole scale. + const result = await client.listModels(provider); + if (cancelled) return; + const entry = result.models.find((m) => m.value === model) ?? (model ? undefined : result.models.find((m) => m.isDefault)); + setChoices(effortChoices(entry?.efforts, entry?.defaultEffort)); + setLive(true); + } else if (kind === "model") { const result = await client.listModels(provider); if (cancelled) return; setChoices(result.models.map((model) => ({ @@ -100,7 +110,7 @@ export function BackendPicker({ kind, provider, sessionName, current, providers, return ( - Select {kind}{kind !== "provider" ? ` (${provider})` : ""}{sessionName ? ` for ${sessionName}` : ""} + Select {kind === "effort" ? "thinking effort" : kind}{kind !== "provider" ? ` (${provider})` : ""}{sessionName ? ` for ${sessionName}` : ""} {loading ? Loading… : options.slice(start, start + 8).map((choice, offset) => ( // A server may list an id twice: the position keeps keys unique. @@ -122,3 +132,20 @@ export function BackendPicker({ kind, provider, sessionName, current, providers, ); } + +/** + * The effort picker's rows (#417): Default (the backend decides), then the + * levels the model takes — or the whole scale when its backend doesn't say, + * marked so: a level it refuses fails the turn with a note. + */ +export function effortChoices(efforts: readonly EffortLevel[] | undefined, defaultEffort?: EffortLevel): BackendChoice[] { + const unknown = efforts === undefined; + return [ + { value: "default", label: "Default", description: defaultEffort ? `the backend decides (${EFFORT_LABELS[defaultEffort]})` : "the backend decides" }, + ...(efforts ?? EFFORT_LEVELS).map((level) => ({ + value: level, + label: EFFORT_LABELS[level], + ...(unknown ? { description: "support unknown for this model" } : {}), + })), + ]; +} diff --git a/src/tui/components/Modal.tsx b/src/tui/components/Modal.tsx index 3255e353..d07813fe 100644 --- a/src/tui/components/Modal.tsx +++ b/src/tui/components/Modal.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import TextInput from "ink-text-input"; import type { ModalState, TuiSession } from "../types.js"; -import type { ProviderInfo, SessionSearchHit } from "../../protocol/types.js"; +import type { EffortLevel, ProviderInfo, SessionSearchHit } from "../../protocol/types.js"; import type { TuiWsClient } from "../ws.js"; import { BackendPicker } from "./BackendPicker.js"; import { NewSessionModal, type NewSessionOptions } from "./NewSessionModal.js"; @@ -37,6 +37,7 @@ interface Props { onSearch?: (query: string, scope: SearchScope) => Promise; /** Set a session's model. */ onSetModel?: (sessionId: string, model: string) => Promise; + onSetEffort?: (sessionId: string, effort: EffortLevel | null) => Promise; } export function Modal(props: Props) { @@ -81,6 +82,7 @@ export function Modal(props: Props) { ); case "model": case "provider": + case "effort": case "account": { // The session the picker was opened for, not whichever is focused now. const { kind, sessionId } = props.modal; @@ -100,15 +102,25 @@ export function Modal(props: Props) { // provider picker must not remount on it: the daemon's info update // arrives before the switch's reply, and a remount would drop the // close that follows a successful pick. - key={kind === "model" ? `${kind}:${provider}` : kind} + key={kind === "model" || kind === "effort" ? `${kind}:${provider}` : kind} kind={kind} provider={provider} + model={target.info.model} sessionName={target.info.name} providers={props.providers} client={props.client} - current={kind === "model" ? target.info.model ?? "" : kind === "provider" ? provider : target.info.account ?? (provider === "claude" ? target.info.claudeAccount : undefined) ?? ""} + current={ + kind === "model" + ? (target.info.model ?? "") + : kind === "effort" + ? (target.info.effort ?? "default") + : kind === "provider" + ? provider + : (target.info.account ?? (provider === "claude" ? target.info.claudeAccount : undefined) ?? "") + } onPick={async (value) => { if (kind === "model") await props.onSetModel?.(sessionId, value); + else if (kind === "effort") await props.onSetEffort?.(sessionId, value === "default" ? null : (value as EffortLevel)); else if (kind === "provider") await props.onSetProvider(sessionId, value); else await props.onSetAccount(sessionId, value, provider); }} diff --git a/src/tui/components/SlashHint.tsx b/src/tui/components/SlashHint.tsx index 297123e0..3515830f 100644 --- a/src/tui/components/SlashHint.tsx +++ b/src/tui/components/SlashHint.tsx @@ -32,6 +32,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: "/mcp", description: "/mcp reconnect — redial a server that is down · /mcp refresh — use servers changed in Claude's config" }, { name: "/search", description: "Search sessions (Ctrl-F) — keywords + semantic + recency; Tab toggles this workspace ↔ all" }, { name: "/model", description: "Choose a model from this backend's catalog, or /model " }, + { name: "/effort", description: "How hard the model thinks: pick, or /effort off|low|…|max|default" }, { name: "/provider", description: "Choose a backend, or /provider (including oai:)" }, { name: "/account", description: "Choose this backend's account, or /account " }, { name: "/who", description: "Show the identity chain (user → agent → sub-agents)" }, diff --git a/src/tui/types.ts b/src/tui/types.ts index a4e22545..9e8d8988 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -72,7 +72,7 @@ export type ModalState = * Backend pickers carry the session they were opened for, so a focus * change while one is open (reconnect, sessions.set) cannot retarget it. */ - | { kind: "model" | "provider"; sessionId: string } + | { kind: "model" | "provider" | "effort"; sessionId: string } /** * The account picker also carries the backend whose accounts it lists: if * the session moves to another backend while it is open, it closes rather diff --git a/src/tui/ws.ts b/src/tui/ws.ts index ab9dc671..f2395876 100644 --- a/src/tui/ws.ts +++ b/src/tui/ws.ts @@ -15,6 +15,7 @@ import type { BackendAccountsInfo, ClientMessage, DaemonMessage, + EffortLevel, ModelsListResultMsg, SessionMessage, ToolState, @@ -320,6 +321,11 @@ export class TuiWsClient { }); } + /** Set the session's thinking effort (#417); null = the backend's default. */ + setEffort(sessionId: string, effort: EffortLevel | null): Promise { + return this.#request({ type: "session.set_effort", id: randomUUID(), sessionId, effort }); + } + setModel( sessionId: string, model: string, diff --git a/web/src/components/HelpModal.tsx b/web/src/components/HelpModal.tsx index 42052fb7..8ccd15ca 100644 --- a/web/src/components/HelpModal.tsx +++ b/web/src/components/HelpModal.tsx @@ -39,6 +39,7 @@ const COMMANDS: readonly CommandDoc[] = [ { usage: "/rotate", desc: "Rotate context — fresh backing session, memory preserved" }, { usage: "/mode ", desc: "Switch mode: guarded · interactive · autonomous" }, { usage: "/model [fallback]", desc: "Switch model: opus / sonnet / haiku, or a full claude-* id" }, + { usage: "/effort [off|minimal|low|medium|high|xhigh|max|default]", desc: "How hard the model thinks; bare /effort opens the picker" }, { usage: "/account ", desc: "Switch a Claude session's account (subscription, gateway, API key)" }, { usage: "/agents", desc: "Show subagents available to this session" }, { usage: "/skills", desc: "Show skills available to this session" }, diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 92c34768..144164a3 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -29,9 +29,11 @@ import { removeSession, } from "../state/sessions"; import { fetchModels, modelCatalog } from "../state/models"; +import { EFFORT_LABELS } from "@highflame/codeoid-core"; import { effectiveMode } from "../lib/session-mode"; import { CAPABILITIES } from "../protocol/types"; -import type { ClientMessage, SessionInfo, SessionMode } from "../protocol/types"; +import { EFFORT_LEVELS } from "../protocol/types"; +import type { ClientMessage, EffortLevel, SessionInfo, SessionMode } from "../protocol/types"; import { openBlackboard } from "../state/blackboard"; import { openExportModal } from "./SessionExportModal"; import { openUndoLast } from "./RewindModal"; @@ -117,6 +119,7 @@ const SessionControls: Component = () => { current={s().model} provider={s().providerId} /> + = (props) => { + const open = effortPickerOpen; + const setOpen = setEffortPickerOpen; + const act = createAction(); + let rootEl: HTMLDivElement | undefined; + useDismissable(() => rootEl, open, () => setOpen(false)); + // The catalog row of the model in use (or the backend's default one). + const entry = () => modelCatalog().find((m) => m.value === props.model) ?? (props.model ? undefined : modelCatalog().find((m) => m.isDefault)); + const known = () => entry()?.efforts; + const levels = (): readonly EffortLevel[] => known() ?? EFFORT_LEVELS; + const choose = (effort: EffortLevel | null) => + act.run({ type: "session.set_effort", id: newRequestId(), sessionId: props.sessionId, effort }, () => setOpen(false)); + return ( +
+ + +
setOpen(false)} + > + + + {(level) => ( + + )} + + +
+ This backend doesn't say which levels the model takes; one it refuses fails the turn with a note. +
+
+ +
+
+
+ ); +}; diff --git a/web/src/components/prompt/PromptBox.tsx b/web/src/components/prompt/PromptBox.tsx index af6f4ba2..bc43d086 100644 --- a/web/src/components/prompt/PromptBox.tsx +++ b/web/src/components/prompt/PromptBox.tsx @@ -36,7 +36,7 @@ import { openHelpModal } from "../HelpModal"; import { openPackBrowser } from "../PackBrowser"; import { openPipelineRunner } from "../PipelineRunner"; import { openSettings } from "../SettingsDrawer"; -import { openModelPicker } from "../SessionControls"; +import { openEffortPicker, openModelPicker } from "../SessionControls"; import { openIdentityDrawer } from "../IdentityDrawer"; import { openExportModal } from "../SessionExportModal"; import { openImportModal } from "../SessionImportModal"; @@ -255,6 +255,7 @@ const PromptBox: Component = () => { }, showHelp: openHelpModal, showModelPicker: openModelPicker, + showEffortPicker: openEffortPicker, }); clearDraft(draftKey()); setText(""); From cc8ec1eed73c768dbf40e6bc1b84a6b200814ebb Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 15:17:31 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20#417=20round=201=20=E2=80=94=20Def?= =?UTF-8?q?ault=20is=20the=20backend's=20own=20on=20Codex=20and=20pi;=20pi?= =?UTF-8?q?'s=20level=20writes=20stay=20out=20of=20the=20user's=20settings?= =?UTF-8?q?;=20Gemini=20levels=20each=20model=20takes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pi saves a thinking-level change as the user's global default: it now runs from an overlay of its agent dir whose settings.json is a fresh copy at each start (logins and sessions linked); Default is the user's own defaultThinkingLevel, else Medium — not whatever a resumed session had. - Codex keeps a turn's effort and reads null as "no override": Default now sends the model's own default effort from model/list (the default model marked too, so pickers find its levels). - Gemini: 3 Pro takes only Low/High; the Gemini CLI's overlay uses values every model takes (2.5 budgets 512–24576, 3.x Low/High); its errors get the effort hint. - Claude: a live change also reaches a retry later in the same turn. - A pending Gemini CLI rebuild is dropped after a switch to another backend; the same level again while it's pending says so. - Forks inherit the effort; creation and delegated changes are audited. - Two sessions building the same overlay at once no longer fail. - Docs: what Default means on each backend; per-model Gemini values; pi's overlay; project Gemini CLI settings outrank the effort. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 30 ++++++++------- packages/protocol/src/types.ts | 2 +- src/daemon/account-apply.ts | 52 +++++++++++++++++++++++--- src/daemon/providers/acp/index.ts | 4 +- src/daemon/providers/claude/index.ts | 2 + src/daemon/providers/codex/index.ts | 29 +++++++++++++- src/daemon/providers/gemini/index.ts | 14 ++++--- src/daemon/providers/interface.ts | 4 ++ src/daemon/providers/pi/index.ts | 29 ++++++++------ src/daemon/session-manager.ts | 18 +++++---- src/daemon/session.ts | 13 +++++-- src/tests/fixtures/fake-codex.ts | 1 + src/tests/fixtures/fake-pi.ts | 13 ++++++- src/tests/provider-codex.test.ts | 5 ++- src/tests/provider-pi.test.ts | 33 +++++++++++++++- src/tests/thinking-effort.test.ts | 46 ++++++++++++++++++++--- web/src/components/SessionControls.tsx | 2 +- 17 files changed, 235 insertions(+), 62 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index b5234c82..295e9904 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -6,9 +6,10 @@ How hard a session's model thinks before it answers, on one scale for every back **Default, Off, Minimal, Low, Medium, High, Extra high (`xhigh`), Max.** -**Default** sends nothing: the backend picks, as it always has. +**Default** is the backend's own choice, as it always was. +Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly: Codex gets the model's own default effort (from its model list), and pi the default in your pi settings (`defaultThinkingLevel`, else Medium). Every other level is mapped to the backend's own setting (below). -A level a backend has no exact match for goes to its nearest one. +A level a backend has no exact match for goes to its nearest one; pi clamps a level a model doesn't take to the nearest it does. ## Setting it @@ -43,23 +44,26 @@ A change never cuts a running turn short. ## What each level means on each backend -| codeoid | Claude | Codex | OpenAI | Qwen | pi | Gemini 2.5 | Gemini 3 / Gemini CLI 3 | llama.cpp | -|---|---|---|---|---|---|---|---|---| -| Off | thinking disabled | `none` | `none` | `low` (can't turn off) | `off` | budget 0 (Pro: 128) | `minimal` | `enable_thinking: false` | -| Minimal | `low` | `minimal` | `minimal` | `low` | `minimal` | budget 512 | `minimal` | `low` | -| Low | `low` | `low` | `low` | `low` | `low` | budget 2048 | `low` | `low` | -| Medium | `medium` | `medium` | `medium` | `medium` | `medium` | budget 8192 | `medium` | `medium` | -| High | `high` | `high` | `high` | `high` | `high` | budget 16384 | `high` | `high` | -| Extra high | `xhigh` | `xhigh` | `xhigh` | `xhigh` | `xhigh` | budget 24576 | `high` | `high` | -| Max | `max` | `max` | `xhigh` | `max` | `max` | budget 32768 (Flash: 24576) | `high` | `high` | +| codeoid | Claude | Codex | OpenAI | Qwen | pi | Gemini 2.5 | Gemini 3 Flash / Pro | Gemini CLI (2.5 / 3) | llama.cpp | +|---|---|---|---|---|---|---|---|---|---| +| Off | thinking disabled | `none` | `none` | `low` (can't turn off) | `off` | budget 0 (Pro: 128) | `minimal` / `low` | 512 / `LOW` | `enable_thinking: false` | +| Minimal | `low` | `minimal` | `minimal` | `low` | `minimal` | budget 512 | `minimal` / `low` | 512 / `LOW` | `low` | +| Low | `low` | `low` | `low` | `low` | `low` | budget 2048 | `low` / `low` | 2048 / `LOW` | `low` | +| Medium | `medium` | `medium` | `medium` | `medium` | `medium` | budget 8192 | `medium` / `high` | 8192 / `HIGH` | `medium` | +| High | `high` | `high` | `high` | `high` | `high` | budget 16384 | `high` / `high` | 16384 / `HIGH` | `high` | +| Extra high | `xhigh` | `xhigh` | `xhigh` | `xhigh` | `xhigh` | budget 24576 | `high` / `high` | 24576 / `HIGH` | `high` | +| Max | `max` | `max` | `xhigh` | `max` | `max` | budget 32768 (Flash: 24576) | `high` / `high` | 24576 / `HIGH` | `high` | Notes: - **Claude** keeps adaptive thinking, with readable summaries, at every level but Off. - **OpenAI** sends `reasoning_effort`; a model that doesn't reason rejects it. -- **Gemini** sends `thinkingConfig` per request: a token budget for 2.5 models, a level for 3.x; other models get nothing. -- **The Gemini CLI** on 2.5 models takes the Gemini 2.5 budgets, but one that every 2.5 model accepts, since the settings can't know which runs: Off is 128 (Pro's minimum) and Max is 24576 (Flash's maximum). +- **Gemini** sends `thinkingConfig` per request: a token budget for 2.5 models (Flash-Lite thinks from 512, or 0 for Off), a level for 3.x (Pro takes only Low and High); other models get nothing. +- **The Gemini CLI** gets values every model of the family accepts, since its settings can't know which model runs: 2.5 budgets from 512 (Flash-Lite's floor; Pro can't turn thinking off, so Off is the lowest budget) to 24576 (Flash's ceiling), and for 3.x only Low and High (all that Pro takes). It runs with a settings overlay of its usual home (an account's, or yours) whose `chat-base-2.5` and `chat-base-3` aliases carry the budget or level; logins and chats are linked, so nothing else changes. The overlays live under `~/.codeoid/accounts/gemini-cli-effort/`. - **llama.cpp** sends both through the model's chat template (`chat_template_kwargs`): `enable_thinking` for Qwen3-style templates, `reasoning_effort` (low / medium / high) for gpt-oss-style ones. A template that reads neither ignores them. +- **pi** saves a thinking-level change as your global default in its settings, so codeoid runs it from an overlay of its agent directory: your logins and sessions are linked, and `settings.json` is a fresh copy at each start, so one session's effort never changes your own pi or other sessions. + The overlays live under `~/.codeoid/accounts/pi-session/`. +- **A project's own Gemini CLI settings** (`.gemini/settings.json` in the workdir) outrank your user settings, so a project that redefines `chat-base-2.5` or `chat-base-3` there overrides the effort on the Gemini CLI. diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index c4b8f24f..0b064da7 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -233,7 +233,7 @@ export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES]; /** * How hard a model thinks before it answers (#417), on one scale for every * backend; each maps it to its own setting (docs/thinking-effort.md). A - * session with no effort set sends nothing: the backend's default applies. + * session with no effort set runs at the backend's own default. */ export const EFFORT_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; export type EffortLevel = (typeof EFFORT_LEVELS)[number]; diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index 1357d604..0c833056 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -74,7 +74,12 @@ export function ensureOverlayHome(spec: OverlaySpec): void { if (readlinkSync(link) === want) continue; unlinkSync(link); } - symlinkSync(want, link); + try { + symlinkSync(want, link); + } catch (err) { + // Another session building the same home got there first (#417): fine if it's the same link. + if ((err as NodeJS.ErrnoException).code !== "EEXIST" || readlinkSync(link) !== want) throw err; + } } // Links into the source whose entry is gone, and links to anything we // now treat as the account's own (a vendor renamed its credential file). @@ -307,10 +312,12 @@ export function geminiEffortSettings(source: string | undefined, effort: EffortL } catch { // An unreadable default is unreadable for the CLI too; start clean. } - // The overlay can't know which 2.5 model runs, so its budgets are ones - // every 2.5 model takes: Pro can't go below 128, Flash above 24576. - const budgets: Record = { off: 128, minimal: 512, low: 2048, medium: 8192, high: 16384, xhigh: 24576, max: 24576 }; - const level = effort === "off" || effort === "minimal" ? "MINIMAL" : effort === "low" ? "LOW" : effort === "medium" ? "MEDIUM" : "HIGH"; + // The overlay can't know which model runs, so it uses values every model + // of the family takes: 2.5 budgets from 512 (Flash-Lite's floor; Pro can't + // turn thinking off) to 24576 (Flash's ceiling); 3.x only Low and High + // (all Pro takes). + const budgets: Record = { off: 512, minimal: 512, low: 2048, medium: 8192, high: 16384, xhigh: 24576, max: 24576 }; + const level = effort === "off" || effort === "minimal" || effort === "low" ? "LOW" : "HIGH"; const configs = (settings.modelConfigs && typeof settings.modelConfigs === "object" ? settings.modelConfigs : {}) as Record; const aliases = (configs.customAliases && typeof configs.customAliases === "object" ? configs.customAliases : {}) as Record; const thinking = (thinkingConfig: Record) => ({ @@ -349,6 +356,41 @@ export function defaultPiAgentDir(env: Record = proc return env.PI_CODING_AGENT_DIR || join(env.HOME || homedir(), ".pi", "agent"); } +/** + * pi's agent dir for a codeoid session (#417): an overlay of the one it would + * use (an account's, or the default) — everything linked, so its logins and + * sessions are the user's — except settings.json, a fresh copy at each start. + * pi saves a thinking-level change as the user's global default in that file; + * the copy keeps one session's effort out of the user's own pi and the others'. + */ +export function applyPiSettingsOverlay( + env: Record, + homesRoot: string = join(getConfigDir(), "accounts"), +): Record { + const base = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); + const home = join(homesRoot, "pi-session", createHash("sha256").update(resolve(base)).digest("hex").slice(0, 12)); + ensureOverlayHome({ + source: base, + target: home, + own: [], + copies: { "settings.json": (src) => src ?? "{}\n" }, + shared: ["sessions"], + }); + return { ...env, PI_CODING_AGENT_DIR: home }; +} + +/** The thinking level pi starts a session at, as the user set it (#417): their settings' default, else pi's, Medium. */ +export function piDefaultThinkingLevel(env: Record): string { + const base = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); + try { + const level = (JSON.parse(readFileSync(join(base, "settings.json"), "utf8")) as { defaultThinkingLevel?: unknown }).defaultThinkingLevel; + if (typeof level === "string" && level) return level; + } catch { + // None, or unreadable: pi's own default. + } + return "medium"; +} + /** * pi's child for `account`: only the account's keys, in an agent dir without * pi's stored logins (`auth.json`) — otherwise pi would prefer a stored login diff --git a/src/daemon/providers/acp/index.ts b/src/daemon/providers/acp/index.ts index 732743b3..1afae808 100644 --- a/src/daemon/providers/acp/index.ts +++ b/src/daemon/providers/acp/index.ts @@ -47,6 +47,7 @@ import { McpProxyLease, type McpProxyMount } from "../../mcp/proxy.js"; import { resolveEnvMap } from "../../mcp/types.js"; import type { ResolvedAccount } from "../../accounts.js"; import { applyGeminiCliAccount, applyGeminiCliEffort } from "../../account-apply.js"; +import { effortRejectionHint } from "../effort.js"; export interface GeminiAcpProviderInit { sessionId: string; @@ -215,9 +216,10 @@ export class GeminiAcpProvider implements SessionProvider { this.#hasQueried = true; void this.#runPrompt(opts).catch((err: unknown) => { + const text = err instanceof Error ? err.message : String(err); this.#push({ type: "error", - message: `gemini-cli: ${err instanceof Error ? err.message : String(err)}`, + message: `gemini-cli: ${text}${opts.effort ? effortRejectionHint(text, opts.effort) : ""}`, }); queue.close(); }); diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index b7d6eb9c..2eea6222 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -633,6 +633,8 @@ export class ClaudeProvider implements SessionProvider { if (!query$ || !this.#consumerTask || effort === "off" || this.#builtEffort === "off") return false; await query$.applyFlagSettings({ effortLevel: effort ? claudeEffort(effort) : null }); this.#builtEffort = effort ?? ""; + // A retry or recovery later in this turn rebuilds from these: at the new effort. + if (this.#lastTurnOpts) this.#lastTurnOpts = { ...this.#lastTurnOpts, effort: effort ?? undefined }; return true; } diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index c9c598aa..757d2f55 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -293,6 +293,10 @@ export class CodexProvider implements SessionProvider { #proc: CodexRpcProcess | null = null; #threadId: string | null = null; + /** Each model's default effort, and which model is the default, from model/list (#417). */ + readonly #modelDefaultEffort = new Map(); + #defaultModelId: string | null = null; + #modelListRead = false; #hasQueried = false; #modelsReported = false; #pendingHistorySeed: string | null = null; @@ -651,8 +655,15 @@ export class CodexProvider implements SessionProvider { description?: string; supportedReasoningEfforts?: Array<{ reasoningEffort?: string }>; defaultReasoningEffort?: string; + isDefault?: boolean; }>; }; + // Each model's own default effort — what Default sends (#417). + for (const m of result.data ?? []) { + const id = m.model ?? m.id; + if (typeof id === "string" && typeof m.defaultReasoningEffort === "string") this.#modelDefaultEffort.set(id, m.defaultReasoningEffort); + if (m.isDefault === true && typeof id === "string") this.#defaultModelId = id; + } return (result.data ?? []) .filter((m) => typeof (m.model ?? m.id) === "string") .map((m) => { @@ -665,6 +676,7 @@ export class CodexProvider implements SessionProvider { id: (m.model ?? m.id) as string, displayName: m.displayName ?? ((m.model ?? m.id) as string), description: m.description, + ...(m.isDefault === true ? { isDefault: true } : {}), ...(efforts ? { efforts } : {}), ...(defaultEffort ? { defaultEffort } : {}), }; @@ -694,6 +706,16 @@ export class CodexProvider implements SessionProvider { // ── Internal ────────────────────────────────────────────────────────── + /** A model's own default effort (once per process, from model/list), or null. */ + async #defaultEffortFor(model: string | undefined): Promise { + if (!this.#modelListRead) { + this.#modelListRead = true; + await this.listModels(); + } + const id = model ?? this.#defaultModelId; + return (id && this.#modelDefaultEffort.get(id)) ?? null; + } + async #startTurn(opts: TurnOpts): Promise { await this.#ensureThread(opts); const seed = this.#pendingHistorySeed; @@ -714,8 +736,10 @@ export class CodexProvider implements SessionProvider { sandboxPolicy: sandboxPolicyWire(sandboxMode, opts.workdir), ...(opts.model ? { model: opts.model } : {}), // Thinking effort (#417). codex keeps a turn's effort for the turns - // after it (a resumed thread too), so none is sent as null: its default. - effort: opts.effort ? toCodexEffort(opts.effort) : null, + // after it (a resumed thread too), and null there means "no override" — + // so Default is sent as the model's own default effort, read from its + // catalog; null only for a model the catalog doesn't know. + effort: opts.effort ? toCodexEffort(opts.effort) : await this.#defaultEffortFor(opts.model), })) as { turn?: { id?: string } }; this.#currentTurnId = result.turn?.id ?? null; } @@ -842,6 +866,7 @@ export class CodexProvider implements SessionProvider { description: m.description, ...(m.efforts ? { efforts: m.efforts } : {}), ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), + ...(m.isDefault ? { isDefault: true } : {}), })), ); } diff --git a/src/daemon/providers/gemini/index.ts b/src/daemon/providers/gemini/index.ts index efecb4ad..67356170 100644 --- a/src/daemon/providers/gemini/index.ts +++ b/src/daemon/providers/gemini/index.ts @@ -346,19 +346,21 @@ export function splitForStateless(opts: TurnOpts): { } /** - * A thinking effort as Gemini's thinkingConfig (#417), by model family: - * 2.5 models take a token budget (0 = off; Pro can't turn thinking off, so - * its floor is 128; Flash's ceiling is 24576), 3.x models a level. Other - * models don't think: nothing is sent. + * A thinking effort as Gemini's thinkingConfig (#417), by model family. + * 2.5 models take a token budget: 0 turns thinking off, except on Pro (its + * floor is 128); Flash-Lite thinks from 512; Flash and Flash-Lite stop at + * 24576. 3.x models take a level — Pro only Low and High, Flash also Minimal + * and Medium. Other models don't think: nothing is sent. */ export function geminiThinkingConfig(model: string, effort: EffortLevel): Record | null { if (/gemini-2\.5/.test(model)) { const pro = /pro/.test(model); + // From Minimal up every budget is at least 512, Flash-Lite's floor. const budgets: Record = { off: 0, minimal: 512, low: 2048, medium: 8192, high: 16384, xhigh: 24576, max: 32768 }; - const budget = Math.min(Math.max(budgets[effort], pro ? 128 : 0), pro ? 32768 : 24576); - return { thinkingBudget: budget }; + return { thinkingBudget: Math.min(Math.max(budgets[effort], pro ? 128 : 0), pro ? 32768 : 24576) }; } if (/gemini-3/.test(model)) { + if (/pro/.test(model)) return { thinkingLevel: effort === "off" || effort === "minimal" || effort === "low" ? "low" : "high" }; const level = effort === "off" || effort === "minimal" ? "minimal" : effort === "low" ? "low" : effort === "medium" ? "medium" : "high"; return { thinkingLevel: level }; } diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index ea370971..0535afa7 100644 --- a/src/daemon/providers/interface.ts +++ b/src/daemon/providers/interface.ts @@ -511,6 +511,8 @@ export interface ModelInfo { /** Thinking efforts the model takes, when the backend reports them (#417). */ efforts?: EffortLevel[]; defaultEffort?: EffortLevel; + /** The backend's default model, when it says. */ + isDefault?: boolean; } /** @@ -531,6 +533,8 @@ export interface CatalogEntry { efforts?: EffortLevel[]; /** The backend's default effort for the model, when it says. */ defaultEffort?: EffortLevel; + /** The backend's default model, when it says (the effort picker finds its levels by it). */ + isDefault?: boolean; } /** diff --git a/src/daemon/providers/pi/index.ts b/src/daemon/providers/pi/index.ts index bae8c942..d3b7e7c5 100644 --- a/src/daemon/providers/pi/index.ts +++ b/src/daemon/providers/pi/index.ts @@ -53,7 +53,7 @@ import type { McpHub } from "../../mcp/hub.js"; import { PiRpcProcess, type PiFrame } from "./rpc.js"; import { translatePiEvent } from "./translate.js"; import type { ResolvedAccount } from "../../accounts.js"; -import { applyPiAccount } from "../../account-apply.js"; +import { applyPiAccount, applyPiSettingsOverlay, piDefaultThinkingLevel } from "../../account-apply.js"; export interface PiProviderInit { sessionId: string; @@ -123,9 +123,9 @@ export class PiProvider implements SessionProvider { #bridgeReady = false; #hasQueried = false; #model: string | null = null; - /** pi's thinking level now, and the one it started with — "default" returns to it (#417). */ + /** pi's thinking level now (#417), and the user's own default, which Default returns to. */ #thinkingLevel: string | null = null; - #startThinkingLevel: string | null = null; + #defaultThinkingLevel: string | null = null; /** Rendered transcript from seedFromHistory() — prepended to the next prompt. */ #pendingHistorySeed: string | null = null; @@ -296,8 +296,8 @@ export class PiProvider implements SessionProvider { if (opts.model && opts.model !== this.#model) { await this.#setModel(opts.model); } - // Thinking effort (#417): pi's levels are codeoid's; none = what pi started with. - const level = opts.effort ?? this.#startThinkingLevel; + // Thinking effort (#417): pi's levels are codeoid's; none = the user's own pi default. + const level = opts.effort ?? this.#defaultThinkingLevel; if (level && level !== this.#thinkingLevel) await this.#setThinkingLevel(level); let message = opts.userMessage; @@ -329,7 +329,8 @@ export class PiProvider implements SessionProvider { cwd: opts.workdir, // Allowlisted env (GHSA-38vh): pi gets its credentials (~/.pi via // HOME, provider API keys), never codeoid's own secrets. - env: applyPiAccount(this.#resolveAccount?.() ?? null, buildPiEnv()), + // pi's thinking-level writes stay in a copy of its settings (#417). + env: this.#piEnv(), onEvent: (frame) => this.#onFrame(frame), onExit: ({ code, signal, stderrTail }) => { this.#bridgeReady = false; @@ -361,10 +362,7 @@ export class PiProvider implements SessionProvider { const state = await this.#proc.request({ type: "get_state" }); const data = state.data as { sessionFile?: string; sessionId?: string; thinkingLevel?: string } | undefined; - if (typeof data?.thinkingLevel === "string") { - this.#startThinkingLevel ??= data.thinkingLevel; - this.#thinkingLevel ??= data.thinkingLevel; - } + if (typeof data?.thinkingLevel === "string") this.#thinkingLevel ??= data.thinkingLevel; const file = data?.sessionFile; if (typeof file === "string" && file.length > 0 && file !== this.#backingSessionId) { this.#backingSessionId = file; @@ -424,12 +422,19 @@ export class PiProvider implements SessionProvider { /** A new thinking effort on the running pi, live (#417): it takes the next request at that level. */ async applyEffort(effort: EffortLevel | null): Promise { - const level = effort ?? this.#startThinkingLevel; - if (!this.#proc || !level) return false; + const level = effort ?? this.#defaultThinkingLevel; + if (!this.#proc?.alive || !level) return false; if (level !== this.#thinkingLevel) await this.#setThinkingLevel(level); return true; } + /** pi's environment: the account's, in an overlay whose settings.json is codeoid's copy. */ + #piEnv(): Record { + const env = applyPiAccount(this.#resolveAccount?.() ?? null, buildPiEnv()); + this.#defaultThinkingLevel = piDefaultThinkingLevel(env); + return applyPiSettingsOverlay(env); + } + async #setThinkingLevel(level: string): Promise { const resp = await this.#proc!.request({ type: "set_thinking_level", level }); if (resp.success === false) throw new Error(`pi could not set thinking level "${level}": ${String(resp.error ?? "")}`); diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index ad7b7636..22241aaf 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -1814,7 +1814,7 @@ mcpHub: this.#mcpHub, ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), ...(m.efforts ? { efforts: m.efforts } : {}), ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), - isDefault: m.value === "default", + isDefault: m.isDefault === true || m.value === "default", })); const previous = this.#modelsCache.get(providerId); if (previous && JSON.stringify(previous) === JSON.stringify(models)) return; @@ -4775,9 +4775,11 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, claudeAccounts: this.#claudeAccounts, - // A fork continues the same conversation — on the same accounts. + // A fork continues the same conversation — on the same accounts, + // thinking as hard (#417). claudeAccount: parent.claudeAccount, accountChoices: parent.accountChoices, + ...(parentInfo.effort ? { effort: parentInfo.effort } : {}), hooks: this.#hooks, providerId, forkedFrom: { @@ -7597,12 +7599,6 @@ mcpHub: this.#mcpHub, return best?.workdir ?? null; } - /** - * Switch the model for a session. Reuses SESSION_SEND scope (same as - * setMode / rotate — anyone who can drive the session can change its - * model). Returns `response.ok` with the resolved model in `data` on - * success; rejects with a 400 when the model id is unknown. - */ /** Set the session's thinking effort (#417). `session:send` on the caller's own session, as for its model. */ async #setEffort(msg: Extract, auth: AuthContext): Promise { if (!hasScope(auth.scopes as string[], SCOPES.SESSION_SEND)) { @@ -7614,6 +7610,12 @@ mcpHub: this.#mcpHub, return { type: "response.ok", requestId: msg.id, data }; } + /** + * Switch the model for a session. Reuses SESSION_SEND scope (same as + * setMode / rotate — anyone who can drive the session can change its + * model). Returns `response.ok` with the resolved model in `data` on + * success; rejects with a 400 when the model id is unknown. + */ async #setModel( msg: Extract, auth: AuthContext, diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 23af6ae7..895b7610 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -1377,7 +1377,7 @@ export class Session { accountId: opts.auth.accountId, projectId: opts.auth.projectId, }); - this.#store.audit(opts.auth.sub, "session.create", this.id, `name=${this.name}`); + this.#store.audit(opts.auth.sub, "session.create", this.id, `name=${this.name}${this.#effort ? ` effort=${this.#effort}` : ""}`); if (this.#effort) this.#store.setSessionEffort(this.id, this.#effort); this.#transcriptStore.saveMeta({ @@ -5069,10 +5069,15 @@ export class Session { const prev = this.#effort; this.#effort = effort; this.#store.setSessionEffort(this.id, effort); - this.#store.audit(sender.sub, "session.set_effort", this.id, `effort=${effort ?? "(default)"} from=${prev ?? "(default)"}`); + this.#store.audit( + sender.sub, + "session.set_effort", + this.id, + `effort=${effort ?? "(default)"} from=${prev ?? "(default)"}${sender.delegationDepth > 0 ? ` delegated=${sender.delegationDepth}` : ""}`, + ); let applies: EffortChangeResult["applies"] = "next-turn"; if (effort === prev) { - applies = "now"; + applies = this.#effortRestartPending ? "restart" : "now"; } else if (this.#provider.effortChange === "restart") { this.#effortRestartPending = true; applies = "restart"; @@ -5101,6 +5106,8 @@ export class Session { async #applyEffortRestart(): Promise { if (!this.#effortRestartPending || this.#mcpBusy()) return; this.#effortRestartPending = false; + // Switched since to a backend that takes it per turn: nothing to rebuild. + if (this.#provider.effortChange !== "restart") return; await this.#teardownProvider(); if (RESEED_AFTER_TEARDOWN.has(this.#provider.id) && this.#accumulator.history.length > 0) { await this.#seedProviderFromHistory(); diff --git a/src/tests/fixtures/fake-codex.ts b/src/tests/fixtures/fake-codex.ts index 7efc2251..bdd9a725 100644 --- a/src/tests/fixtures/fake-codex.ts +++ b/src/tests/fixtures/fake-codex.ts @@ -432,6 +432,7 @@ async function handle(frame: Record): Promise { description: "Balanced agentic coding model.", supportedReasoningEfforts: [{ reasoningEffort: "none" }, { reasoningEffort: "low" }, { reasoningEffort: "high" }, { reasoningEffort: "ultra" }], defaultReasoningEffort: "low", + isDefault: true, }, ], }, diff --git a/src/tests/fixtures/fake-pi.ts b/src/tests/fixtures/fake-pi.ts index 91cf15a7..b02dc02c 100644 --- a/src/tests/fixtures/fake-pi.ts +++ b/src/tests/fixtures/fake-pi.ts @@ -261,10 +261,21 @@ function handle(cmd: Record): void { case "set_model": respond(cmd.id, "set_model", { data: {} }); return; - case "set_thinking_level": + case "set_thinking_level": { thinkingLevel = String(cmd.level); + // As real pi does: the level is saved as the global default, in place, in its agent dir's settings.json. + const dir = process.env.PI_CODING_AGENT_DIR; + if (dir) { + const file = `${dir}/settings.json`; + let settings: Record = {}; + try { + settings = JSON.parse(require("node:fs").readFileSync(file, "utf8")); + } catch {} + require("node:fs").writeFileSync(file, JSON.stringify({ ...settings, defaultThinkingLevel: thinkingLevel })); + } respond(cmd.id, "set_thinking_level"); return; + } case "switch_session": case "new_session": respond(cmd.id, String(cmd.type), { data: { cancelled: false } }); diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts index 46a691bd..cd720afd 100644 --- a/src/tests/provider-codex.test.ts +++ b/src/tests/provider-codex.test.ts @@ -676,6 +676,7 @@ describe("CodexProvider over fake-codex", () => { // Its efforts on codeoid's scale (#417): "none" is Off; "ultra" has no level. efforts: ["off", "low", "high"], defaultEffort: "low", + isDefault: true, }, ]); }); @@ -1304,10 +1305,10 @@ describe("CodexProvider – thinking effort (#417)", () => { return JSON.parse((events.find((e) => e.type === "text_done") as { content: string }).content).effort; }; - it("sends it on every turn — Off as codex's none, and none set as null (codex keeps a turn's effort)", async () => { + it("sends it on every turn — Off as codex's none, Default as the model's own default (codex keeps a turn's; null wouldn't reset it)", async () => { expect(await effortSeen("high")).toBe("high"); expect(await effortSeen("off")).toBe("none"); - expect(await effortSeen()).toBeNull(); + expect(await effortSeen()).toBe("low"); // the default model's defaultReasoningEffort }); it("reports each model's efforts on codeoid's scale, dropping ones it has no level for", async () => { diff --git a/src/tests/provider-pi.test.ts b/src/tests/provider-pi.test.ts index 8875e620..365172f1 100644 --- a/src/tests/provider-pi.test.ts +++ b/src/tests/provider-pi.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Store } from "../daemon/store.js"; @@ -57,13 +57,23 @@ let tmp: string; let store: Store; let provider: PiProvider | null; +let savedEnv: Record = {}; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "codeoid-pi-test-")); store = new Store(join(tmp, "codeoid.db")); provider = null; + // pi runs from an overlay of its agent dir under codeoid's config (#417): + // both kept in the test's own directory, never the user's. + savedEnv = { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }; + process.env.XDG_CONFIG_HOME = join(tmp, "xdg"); + process.env.PI_CODING_AGENT_DIR = join(tmp, "pi-agent"); }); afterEach(async () => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } await provider?.teardown(); try { store.close(); @@ -420,7 +430,7 @@ describe("PiProvider – thinking effort (#417)", () => { expect(said(await collect(p.runTurn(turnOpts("echo-thinking", { effort: "high" })).events))).toBe("thinking:high"); expect(await p.applyEffort("low")).toBe(true); expect(said(await collect(p.runTurn(turnOpts("echo-thinking", { effort: "low" })).events))).toBe("thinking:low"); - expect(said(await collect(p.runTurn(turnOpts("echo-thinking")).events))).toBe("thinking:medium"); // what pi started with + expect(said(await collect(p.runTurn(turnOpts("echo-thinking")).events))).toBe("thinking:medium"); // pi's own default await p.teardown(); }); @@ -430,3 +440,22 @@ describe("PiProvider – thinking effort (#417)", () => { expect(piThinkingLevels({ reasoning: true, thinkingLevelMap: { minimal: null, xhigh: "x", max: "m" } })).toEqual(["off", "low", "medium", "high", "xhigh", "max"]); }); }); + +describe("PiProvider – thinking effort stays in the session (#417)", () => { + it("Default is the user's own pi default; pi's writes of a level never reach their settings", async () => { + const agent = join(tmp, "pi-agent"); + mkdirSync(agent, { recursive: true }); + writeFileSync(join(agent, "settings.json"), JSON.stringify({ defaultThinkingLevel: "low", theme: "dark" })); + writeFileSync(join(agent, "auth.json"), "{}"); + const p = makeProvider(); + expect((await collect(p.runTurn(turnOpts("echo-thinking", { effort: "xhigh" })).events)).find((e) => e.type === "text_done")).toMatchObject({ content: "thinking:xhigh" }); + expect((await collect(p.runTurn(turnOpts("echo-thinking")).events)).find((e) => e.type === "text_done")).toMatchObject({ content: "thinking:low" }); + await p.teardown(); + // pi ran from an overlay: its own settings copy, the user's logins linked. + expect(JSON.parse(readFileSync(join(agent, "settings.json"), "utf8"))).toEqual({ defaultThinkingLevel: "low", theme: "dark" }); + const overlayRoot = join(tmp, "xdg", "codeoid", "accounts", "pi-session"); + const [overlay] = readdirSync(overlayRoot); + expect(lstatSync(join(overlayRoot, overlay!, "auth.json")).isSymbolicLink()).toBe(true); + expect(lstatSync(join(overlayRoot, overlay!, "settings.json")).isSymbolicLink()).toBe(false); + }); +}); diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index 91498e2c..97e1ac4c 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -44,13 +44,17 @@ describe("each backend's reading of the scale", () => { expect(llamaCppEffortParams("minimal")).toMatchObject({ reasoning_effort: "low" }); }); - it("Gemini: a budget for 2.5 (Pro can't go below 128, Flash above 24576), a level for 3.x, nothing for others", () => { + it("Gemini: a budget for 2.5 within each model's range, a level for 3.x within each model's set, nothing for others", () => { expect(geminiThinkingConfig("gemini-2.5-flash", "off")).toEqual({ thinkingBudget: 0 }); - expect(geminiThinkingConfig("gemini-2.5-pro", "off")).toEqual({ thinkingBudget: 128 }); + expect(geminiThinkingConfig("gemini-2.5-pro", "off")).toEqual({ thinkingBudget: 128 }); // Pro can't turn it off + expect(geminiThinkingConfig("gemini-2.5-flash-lite", "off")).toEqual({ thinkingBudget: 0 }); + expect(geminiThinkingConfig("gemini-2.5-flash-lite", "minimal")).toEqual({ thinkingBudget: 512 }); expect(geminiThinkingConfig("gemini-2.5-flash", "max")).toEqual({ thinkingBudget: 24576 }); expect(geminiThinkingConfig("gemini-2.5-pro", "max")).toEqual({ thinkingBudget: 32768 }); - expect(geminiThinkingConfig("gemini-3-pro-preview", "xhigh")).toEqual({ thinkingLevel: "high" }); + expect(geminiThinkingConfig("gemini-3-pro-preview", "medium")).toEqual({ thinkingLevel: "high" }); // Pro: Low or High + expect(geminiThinkingConfig("gemini-3-pro-preview", "minimal")).toEqual({ thinkingLevel: "low" }); expect(geminiThinkingConfig("gemini-3-flash", "off")).toEqual({ thinkingLevel: "minimal" }); + expect(geminiThinkingConfig("gemini-3-flash", "medium")).toEqual({ thinkingLevel: "medium" }); expect(geminiThinkingConfig("gemini-1.5-pro", "high")).toBeNull(); }); @@ -63,9 +67,11 @@ describe("each backend's reading of the scale", () => { modelConfig: { generateContentConfig: { thinkingConfig: { thinkingBudget: 2048 } } }, }); expect(out.modelConfigs.customAliases["chat-base-3"].modelConfig.generateContentConfig.thinkingConfig).toEqual({ thinkingLevel: "LOW" }); - // Values every 2.5 model takes: the settings can't know which runs. + // Values every model of the family takes: the settings can't know which runs. const off = JSON.parse(geminiEffortSettings(undefined, "off")); - expect(off.modelConfigs.customAliases["chat-base-2.5"].modelConfig.generateContentConfig.thinkingConfig.thinkingBudget).toBe(128); + expect(off.modelConfigs.customAliases["chat-base-2.5"].modelConfig.generateContentConfig.thinkingConfig.thinkingBudget).toBe(512); + const medium = JSON.parse(geminiEffortSettings(undefined, "medium")); + expect(medium.modelConfigs.customAliases["chat-base-3"].modelConfig.generateContentConfig.thinkingConfig.thinkingLevel).toBe("HIGH"); expect(JSON.parse(geminiEffortSettings("not json", "max")).modelConfigs.customAliases["chat-base-2.5"].modelConfig.generateContentConfig.thinkingConfig.thinkingBudget).toBe(24576); }); @@ -216,6 +222,36 @@ describe("a session's thinking effort", () => { expect(providers[0]!.capturedOpts.at(-1)?.effort).toBe("high"); await turn(m, sid); expect(providers[0]!.teardownCount).toBe(1); // once + // The same level again while a restart is still to come says so. + expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "restart" } }); + expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "restart" } }); + }); + + it("a fork thinks as hard as the session it came from", async () => { + const { m } = manager(() => new MockSessionProvider("claude", [])); + const sid = await create(m, "xhigh"); + await turn(m, sid); + const r = (await m.handle({ type: "session.fork", id: "f", sessionId: sid, name: "f" }, auth([...ALL, SCOPES.SESSION_READ]), client)) as { + type: string; + data?: { id?: string; session?: { id: string } }; + error?: string; + }; + expect(r.type).toBe("response.ok"); + const forkId = r.data?.session?.id ?? r.data?.id; + expect(m._sessionForTest(forkId!)!.toInfo().effort).toBe("xhigh"); + }); + + it("on the record at creation, and marked when a delegated token changes it", async () => { + const { m, store } = manager(() => new MockSessionProvider("claude", [])); + const sid = await create(m, "max"); + expect(await setEffort(m, sid, "low", { ...auth(), delegationDepth: 1 })).toMatchObject({ type: "response.ok" }); + store.close(); + const db = new Database(join(tmp, "codeoid.db"), { readonly: true }); + const created = db.query("SELECT detail FROM audit_log WHERE action = 'session.create'").get() as { detail: string }; + const changed = db.query("SELECT detail FROM audit_log WHERE action = 'session.set_effort'").get() as { detail: string }; + db.close(); + expect(created.detail).toBe("name=w effort=max"); + expect(changed.detail).toBe("effort=low from=max delegated=1"); }); it("is the session owner's to change: session:send, their own session", async () => { diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 144164a3..7d0b8812 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -877,7 +877,7 @@ const EffortPicker: Component<{ sessionId: string; current?: EffortLevel; model? class={`flex w-full items-center justify-between px-3 py-1.5 text-left text-[12px] transition hover:bg-bg-hover disabled:opacity-50 ${ !props.current ? "bg-bg-active text-fg" : "text-fg-muted" }`} - title="Send no effort: the backend decides" + title="The backend's own default" > Default From da48f6b9516a75234772e0051226f646f7b14c67 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 15:34:44 +0800 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20#417=20round=202=20=E2=80=94=20Cod?= =?UTF-8?q?ex=20Default=20is=20the=20user's=20configured=20effort;=20pi's?= =?UTF-8?q?=20overlay=20links=20logins=20made=20later;=20overlays=20go=20w?= =?UTF-8?q?ith=20their=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex: Default now sends what codex would use unasked — the user's model_reasoning_effort (config/read, project layers included), else the running thread's model's own default — instead of the catalog's default model's, which overrode the user's config. The catalog no longer marks a default model (the configured one may differ). - pi: auth.json is made in the user's dir when missing (as pi does) so the overlay always links it, and an overlay holding an empty one of its own is relinked; the level is sent again after a model change (pi re-applies its own); a ~ in PI_CODING_AGENT_DIR is read as pi does. - Overlays: lock entries are never linked; a link removed by a concurrent build is tolerated; an account's pi and Gemini CLI overlays are trashed with it. - Docs: Codex's Default; pi's lock limit with a pi you run yourself. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 4 ++- src/daemon/account-apply.ts | 56 +++++++++++++++++++++++------ src/daemon/accounts.ts | 26 +++++++++++--- src/daemon/providers/codex/index.ts | 53 ++++++++++++++++----------- src/daemon/providers/interface.ts | 4 --- src/daemon/providers/pi/index.ts | 2 ++ src/daemon/session-manager.ts | 2 +- src/tests/fixtures/fake-codex.ts | 7 ++-- src/tests/fixtures/fake-pi.ts | 2 ++ src/tests/provider-codex.test.ts | 13 +++++-- src/tests/provider-pi.test.ts | 10 ++++++ src/tests/thinking-effort.test.ts | 44 ++++++++++++++++++++++- 12 files changed, 175 insertions(+), 48 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index 295e9904..f7571dc7 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -7,7 +7,7 @@ How hard a session's model thinks before it answers, on one scale for every back **Default, Off, Minimal, Low, Medium, High, Extra high (`xhigh`), Max.** **Default** is the backend's own choice, as it always was. -Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly: Codex gets the model's own default effort (from its model list), and pi the default in your pi settings (`defaultThinkingLevel`, else Medium). +Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (project config included), else the running model's own default; pi gets the default in your pi settings (`defaultThinkingLevel`, else Medium). Every other level is mapped to the backend's own setting (below). A level a backend has no exact match for goes to its nearest one; pi clamps a level a model doesn't take to the nearest it does. @@ -66,4 +66,6 @@ Notes: A template that reads neither ignores them. - **pi** saves a thinking-level change as your global default in its settings, so codeoid runs it from an overlay of its agent directory: your logins and sessions are linked, and `settings.json` is a fresh copy at each start, so one session's effort never changes your own pi or other sessions. The overlays live under `~/.codeoid/accounts/pi-session/`. + One limit: pi locks `auth.json` by the path it was given, so a pi you run yourself on the same login doesn't share codeoid's lock; in the rare case both refresh an expiring OAuth login at the same moment, one may have to sign in again. +- The Gemini CLI and pi overlays of an account's home are removed with the account. - **A project's own Gemini CLI settings** (`.gemini/settings.json` in the workdir) outrank your user settings, so a project that redefines `chat-base-2.5` or `chat-base-3` there overrides the effort on the Gemini CLI. diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index 0c833056..ed529387 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -15,10 +15,9 @@ import { spawn } from "node:child_process"; import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { createHash } from "node:crypto"; import { atomicWrite, getConfigDir } from "../config.js"; import type { EffortLevel } from "../protocol/types.js"; -import { AccountError, type ResolvedAccount } from "./accounts.js"; +import { AccountError, overlayKey, type ResolvedAccount } from "./accounts.js"; // ── Overlay homes ───────────────────────────────────────────────────────────── @@ -60,7 +59,7 @@ export function ensureOverlayHome(spec: OverlaySpec): void { // No default home yet: the account starts from nothing but its own files. } for (const name of entries) { - if (own.has(name) || name in copies) continue; + if (own.has(name) || name in copies || isLockEntry(name)) continue; const link = join(spec.target, name); const want = join(source, name); let stat: ReturnType | null = null; @@ -72,7 +71,7 @@ export function ensureOverlayHome(spec: OverlaySpec): void { if (stat && !stat.isSymbolicLink()) continue; // the account's own copy if (stat?.isSymbolicLink()) { if (readlinkSync(link) === want) continue; - unlinkSync(link); + unlinkLink(link); } try { symlinkSync(want, link); @@ -92,7 +91,7 @@ export function ensureOverlayHome(spec: OverlaySpec): void { continue; } if (!isLink) continue; - if (own.has(name) || name in copies || !entries.includes(name)) unlinkSync(link); + if (own.has(name) || name in copies || isLockEntry(name) || !entries.includes(name)) unlinkLink(link); } for (const [name, transform] of Object.entries(copies)) { const from = join(source, name); @@ -286,7 +285,7 @@ export function applyGeminiCliEffort( ): Record { if (!effort) return env; const base = env.GEMINI_CLI_HOME || defaultGeminiCliRoot(); - const key = `${createHash("sha256").update(resolve(base)).digest("hex").slice(0, 12)}-${effort}`; + const key = `${overlayKey(base)}-${effort}`; const home = join(homesRoot, "gemini-cli-effort", key); mkdirSync(home, { recursive: true, mode: 0o700 }); ensureOverlayHome({ @@ -356,19 +355,54 @@ export function defaultPiAgentDir(env: Record = proc return env.PI_CODING_AGENT_DIR || join(env.HOME || homedir(), ".pi", "agent"); } +/** pi's agent dir, as pi reads PI_CODING_AGENT_DIR (a leading ~ is home). */ +function piAgentDirOf(env: Record): string { + const dir = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); + return dir === "~" || dir.startsWith("~/") ? join(env.HOME || homedir(), dir.slice(1)) : dir; +} + +/** A lock another process holds right now (proper-lockfile's `.lock`): never linked into an overlay. */ +function isLockEntry(name: string): boolean { + return name.endsWith(".lock"); +} + +/** Remove a link that another session building the same overlay may have removed first. */ +function unlinkLink(link: string): void { + try { + unlinkSync(link); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } +} + /** * pi's agent dir for a codeoid session (#417): an overlay of the one it would * use (an account's, or the default) — everything linked, so its logins and * sessions are the user's — except settings.json, a fresh copy at each start. - * pi saves a thinking-level change as the user's global default in that file; - * the copy keeps one session's effort out of the user's own pi and the others'. + * pi saves a thinking-level change (and a model change) as the user's global + * default in that file; the copy keeps them out of the user's own pi. (Sessions + * on the same base share the copy — codeoid sends each its level every turn.) + * pi locks auth.json by the path it was given, so a pi run elsewhere on the + * user's own dir doesn't share that lock (docs/thinking-effort.md). */ export function applyPiSettingsOverlay( env: Record, homesRoot: string = join(getConfigDir(), "accounts"), ): Record { - const base = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); - const home = join(homesRoot, "pi-session", createHash("sha256").update(resolve(base)).digest("hex").slice(0, 12)); + const base = piAgentDirOf(env); + // pi creates its auth.json when it's missing — then it must be the user's, + // not a file of the overlay's own that a later `pi /login` never reaches. + mkdirSync(base, { recursive: true, mode: 0o700 }); + const auth = join(base, "auth.json"); + if (!existsSync(auth)) writeFileSync(auth, "{}", { mode: 0o600, flag: "wx" }); + const home = join(homesRoot, "pi-session", overlayKey(base)); + // An overlay built before that took an empty one of its own: link the user's instead. + try { + const own = join(home, "auth.json"); + if (!lstatSync(own).isSymbolicLink() && readFileSync(own, "utf8").trim() === "{}") unlinkSync(own); + } catch { + // None yet. + } ensureOverlayHome({ source: base, target: home, @@ -381,7 +415,7 @@ export function applyPiSettingsOverlay( /** The thinking level pi starts a session at, as the user set it (#417): their settings' default, else pi's, Medium. */ export function piDefaultThinkingLevel(env: Record): string { - const base = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); + const base = piAgentDirOf(env); try { const level = (JSON.parse(readFileSync(join(base, "settings.json"), "utf8")) as { defaultThinkingLevel?: unknown }).defaultThinkingLevel; if (typeof level === "string" && level) return level; diff --git a/src/daemon/accounts.ts b/src/daemon/accounts.ts index 0b2aac8a..eff40c11 100644 --- a/src/daemon/accounts.ts +++ b/src/daemon/accounts.ts @@ -14,7 +14,7 @@ import { createHash, randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { atomicWrite, getConfigDir } from "../config.js"; import { type AccountInfo, @@ -523,12 +523,23 @@ export class AccountStore { #discardHome(backend: string, name: string): void { const home = this.homeOf(backend, name); if (!existsSync(home)) return; - const trash = join(this.#homesRoot, ".trash"); + // ...and the overlays built over it for a thinking effort (#417): they hold a copy of its settings. + const key = overlayKey(home); + const overlays = [join(this.#homesRoot, "pi-session", key)]; try { - mkdirSync(trash, { recursive: true, mode: 0o700 }); - renameSync(home, join(trash, `${Date.now()}-${randomBytes(4).toString("hex")}`)); + for (const n of readdirSync(join(this.#homesRoot, "gemini-cli-effort"))) if (n.startsWith(`${key}-`)) overlays.push(join(this.#homesRoot, "gemini-cli-effort", n)); } catch { - rmSync(home, { recursive: true, force: true }); + // None built. + } + const trash = join(this.#homesRoot, ".trash"); + for (const dir of [home, ...overlays]) { + if (!existsSync(dir)) continue; + try { + mkdirSync(trash, { recursive: true, mode: 0o700 }); + renameSync(dir, join(trash, `${Date.now()}-${randomBytes(4).toString("hex")}`)); + } catch { + rmSync(dir, { recursive: true, force: true }); + } } } @@ -590,3 +601,8 @@ export class AccountStore { /** Key variables pi reads, for the env allowlist of a pi child. */ export const PI_ACCOUNT_VARIABLES: readonly string[] = PI_KEY_VARIABLES; + +/** The key of an overlay built over `base` (a backend's home) for a thinking effort (#417). */ +export function overlayKey(base: string): string { + return createHash("sha256").update(resolve(base)).digest("hex").slice(0, 12); +} diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index 757d2f55..fc86a5ca 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -293,10 +293,12 @@ export class CodexProvider implements SessionProvider { #proc: CodexRpcProcess | null = null; #threadId: string | null = null; - /** Each model's default effort, and which model is the default, from model/list (#417). */ + /** Each model's default effort from model/list, the model the thread runs, and the + * user's own configured effort (config/read) — what Default sends (#417). */ readonly #modelDefaultEffort = new Map(); - #defaultModelId: string | null = null; - #modelListRead = false; + #threadModel: string | null = null; + #configEffort: string | null | undefined = undefined; + #modelListTried = false; #hasQueried = false; #modelsReported = false; #pendingHistorySeed: string | null = null; @@ -471,8 +473,9 @@ export class CodexProvider implements SessionProvider { if (!forked.thread?.id) throw new Error("thread/fork returned no thread id"); return forked.thread.id; } - const started = (await this.#proc.request("thread/start", config)) as { thread?: { id?: string } }; + const started = (await this.#proc.request("thread/start", config)) as { thread?: { id?: string }; model?: string }; if (!started.thread?.id) throw new Error("thread/start returned no thread id"); + this.#threadModel = started.model ?? null; await this.#proc.request("thread/inject_items", { threadId: started.thread.id, items: pending.items }); return started.thread.id; } catch (err) { @@ -655,14 +658,12 @@ export class CodexProvider implements SessionProvider { description?: string; supportedReasoningEfforts?: Array<{ reasoningEffort?: string }>; defaultReasoningEffort?: string; - isDefault?: boolean; }>; }; - // Each model's own default effort — what Default sends (#417). + // Each model's own default effort — what Default falls back to (#417). for (const m of result.data ?? []) { const id = m.model ?? m.id; if (typeof id === "string" && typeof m.defaultReasoningEffort === "string") this.#modelDefaultEffort.set(id, m.defaultReasoningEffort); - if (m.isDefault === true && typeof id === "string") this.#defaultModelId = id; } return (result.data ?? []) .filter((m) => typeof (m.model ?? m.id) === "string") @@ -676,7 +677,6 @@ export class CodexProvider implements SessionProvider { id: (m.model ?? m.id) as string, displayName: m.displayName ?? ((m.model ?? m.id) as string), description: m.description, - ...(m.isDefault === true ? { isDefault: true } : {}), ...(efforts ? { efforts } : {}), ...(defaultEffort ? { defaultEffort } : {}), }; @@ -706,14 +706,25 @@ export class CodexProvider implements SessionProvider { // ── Internal ────────────────────────────────────────────────────────── - /** A model's own default effort (once per process, from model/list), or null. */ - async #defaultEffortFor(model: string | undefined): Promise { - if (!this.#modelListRead) { - this.#modelListRead = true; + /** turn/start's effort (#417): the session's, else what codex would use unasked — or nothing when that can't be known. */ + async #effortParam(opts: TurnOpts): Promise<{ effort?: string }> { + if (opts.effort) return { effort: toCodexEffort(opts.effort) }; + if (this.#configEffort === undefined) { + try { + const read = (await this.#proc!.request("config/read", { cwd: opts.workdir })) as { config?: { model_reasoning_effort?: string | null } }; + this.#configEffort = read.config?.model_reasoning_effort ?? null; + } catch { + // Unknown this time; asked again next turn. + } + } + if (this.#configEffort) return { effort: this.#configEffort }; + const model = opts.model ?? this.#threadModel; + if (model && !this.#modelDefaultEffort.has(model) && !this.#modelListTried) { + this.#modelListTried = true; // once: a model the list doesn't carry stays unknown await this.listModels(); } - const id = model ?? this.#defaultModelId; - return (id && this.#modelDefaultEffort.get(id)) ?? null; + const fallback = model ? this.#modelDefaultEffort.get(model) : undefined; + return fallback ? { effort: fallback } : {}; } async #startTurn(opts: TurnOpts): Promise { @@ -737,9 +748,10 @@ export class CodexProvider implements SessionProvider { ...(opts.model ? { model: opts.model } : {}), // Thinking effort (#417). codex keeps a turn's effort for the turns // after it (a resumed thread too), and null there means "no override" — - // so Default is sent as the model's own default effort, read from its - // catalog; null only for a model the catalog doesn't know. - effort: opts.effort ? toCodexEffort(opts.effort) : await this.#defaultEffortFor(opts.model), + // so Default is sent as the effort codex itself would use: the user's + // configured one, else the running model's own default (the same + // value codex picks unasked; sending it resets an earlier override). + ...(await this.#effortParam(opts)), })) as { turn?: { id?: string } }; this.#currentTurnId = result.turn?.id ?? null; } @@ -810,9 +822,10 @@ export class CodexProvider implements SessionProvider { try { const resumed = (await this.#proc.request("thread/resume", { threadId: this.#backingSessionId, - })) as { thread?: { id?: string } }; + })) as { thread?: { id?: string }; model?: string }; if (resumed.thread?.id) { this.#threadId = resumed.thread.id; + this.#threadModel = resumed.model ?? null; return; } } catch { @@ -828,8 +841,9 @@ export class CodexProvider implements SessionProvider { this.onHistoryLost?.(); } } - const started = (await this.#proc.request("thread/start", threadConfig(opts))) as { thread?: { id?: string } }; + const started = (await this.#proc.request("thread/start", threadConfig(opts))) as { thread?: { id?: string }; model?: string }; if (!started.thread?.id) throw new Error("codex thread/start returned no thread id"); + this.#threadModel = started.model ?? null; this.#adoptThread(started.thread.id); await this.#reportModelsOnce(); } @@ -866,7 +880,6 @@ export class CodexProvider implements SessionProvider { description: m.description, ...(m.efforts ? { efforts: m.efforts } : {}), ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), - ...(m.isDefault ? { isDefault: true } : {}), })), ); } diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index 0535afa7..ea370971 100644 --- a/src/daemon/providers/interface.ts +++ b/src/daemon/providers/interface.ts @@ -511,8 +511,6 @@ export interface ModelInfo { /** Thinking efforts the model takes, when the backend reports them (#417). */ efforts?: EffortLevel[]; defaultEffort?: EffortLevel; - /** The backend's default model, when it says. */ - isDefault?: boolean; } /** @@ -533,8 +531,6 @@ export interface CatalogEntry { efforts?: EffortLevel[]; /** The backend's default effort for the model, when it says. */ defaultEffort?: EffortLevel; - /** The backend's default model, when it says (the effort picker finds its levels by it). */ - isDefault?: boolean; } /** diff --git a/src/daemon/providers/pi/index.ts b/src/daemon/providers/pi/index.ts index d3b7e7c5..5c6cb7b6 100644 --- a/src/daemon/providers/pi/index.ts +++ b/src/daemon/providers/pi/index.ts @@ -453,6 +453,8 @@ export class PiProvider implements SessionProvider { throw new Error(`pi could not switch model to "${model}": ${String(resp.error ?? "")}`); } this.#model = model; + // pi re-applies its own (clamped) level on a model change: send ours again (#417). + this.#thinkingLevel = null; } async #fetchStats(): Promise { diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 22241aaf..34d607a5 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -1814,7 +1814,7 @@ mcpHub: this.#mcpHub, ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), ...(m.efforts ? { efforts: m.efforts } : {}), ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), - isDefault: m.isDefault === true || m.value === "default", + isDefault: m.value === "default", })); const previous = this.#modelsCache.get(providerId); if (previous && JSON.stringify(previous) === JSON.stringify(models)) return; diff --git a/src/tests/fixtures/fake-codex.ts b/src/tests/fixtures/fake-codex.ts index bdd9a725..541de49d 100644 --- a/src/tests/fixtures/fake-codex.ts +++ b/src/tests/fixtures/fake-codex.ts @@ -394,7 +394,7 @@ async function handle(frame: Record): Promise { const bad = sandboxPolicyError(params.sandboxPolicy); if (bad) return void send({ id, error: { code: -32602, message: bad } }); lastThreadPolicies = { approvalPolicy: params.approvalPolicy, sandboxPolicy: params.sandboxPolicy }; - send({ id, result: { thread: { id: "codex-thread-1" } } }); + send({ id, result: { thread: { id: "codex-thread-1" }, model: "gpt-5.6-terra" } }); send({ method: "thread/started", params: { thread: { id: "codex-thread-1" } } }); break; } @@ -420,6 +420,10 @@ async function handle(frame: Record): Promise { } send({ id, result: { thread: { id: String(params.threadId ?? "codex-thread-1") } } }); break; + case "config/read": + // The user's configured effort (#417), set by the test through the env. + send({ id, result: { config: { model_reasoning_effort: process.env.CODEX_FAKE_CONFIG_EFFORT || null }, origins: {}, layers: null } }); + break; case "model/list": send({ id, @@ -432,7 +436,6 @@ async function handle(frame: Record): Promise { description: "Balanced agentic coding model.", supportedReasoningEfforts: [{ reasoningEffort: "none" }, { reasoningEffort: "low" }, { reasoningEffort: "high" }, { reasoningEffort: "ultra" }], defaultReasoningEffort: "low", - isDefault: true, }, ], }, diff --git a/src/tests/fixtures/fake-pi.ts b/src/tests/fixtures/fake-pi.ts index b02dc02c..b043da41 100644 --- a/src/tests/fixtures/fake-pi.ts +++ b/src/tests/fixtures/fake-pi.ts @@ -259,6 +259,8 @@ function handle(cmd: Record): void { }); return; case "set_model": + // pi re-applies its own level on a model change (clamped, or its default). + thinkingLevel = "medium"; respond(cmd.id, "set_model", { data: {} }); return; case "set_thinking_level": { diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts index cd720afd..cc1ec026 100644 --- a/src/tests/provider-codex.test.ts +++ b/src/tests/provider-codex.test.ts @@ -676,7 +676,6 @@ describe("CodexProvider over fake-codex", () => { // Its efforts on codeoid's scale (#417): "none" is Off; "ultra" has no level. efforts: ["off", "low", "high"], defaultEffort: "low", - isDefault: true, }, ]); }); @@ -1305,10 +1304,18 @@ describe("CodexProvider – thinking effort (#417)", () => { return JSON.parse((events.find((e) => e.type === "text_done") as { content: string }).content).effort; }; - it("sends it on every turn — Off as codex's none, Default as the model's own default (codex keeps a turn's; null wouldn't reset it)", async () => { + it("sends it on every turn — Off as codex's none; Default as what codex would use unasked (null wouldn't reset an override)", async () => { expect(await effortSeen("high")).toBe("high"); expect(await effortSeen("off")).toBe("none"); - expect(await effortSeen()).toBe("low"); // the default model's defaultReasoningEffort + expect(await effortSeen()).toBe("low"); // the running model's own default (no configured effort) + const saved = process.env.CODEX_FAKE_CONFIG_EFFORT; + process.env.CODEX_FAKE_CONFIG_EFFORT = "xhigh"; + try { + expect(await effortSeen()).toBe("xhigh"); // the user's model_reasoning_effort wins + } finally { + if (saved === undefined) delete process.env.CODEX_FAKE_CONFIG_EFFORT; + else process.env.CODEX_FAKE_CONFIG_EFFORT = saved; + } }); it("reports each model's efforts on codeoid's scale, dropping ones it has no level for", async () => { diff --git a/src/tests/provider-pi.test.ts b/src/tests/provider-pi.test.ts index 365172f1..f795084a 100644 --- a/src/tests/provider-pi.test.ts +++ b/src/tests/provider-pi.test.ts @@ -459,3 +459,13 @@ describe("PiProvider – thinking effort stays in the session (#417)", () => { expect(lstatSync(join(overlayRoot, overlay!, "settings.json")).isSymbolicLink()).toBe(false); }); }); + +describe("PiProvider – thinking effort across a model change (#417)", () => { + it("sends the level again after a model change, which re-applies pi's own", async () => { + const p = makeProvider(); + await collect(p.runTurn(turnOpts("echo-thinking", { effort: "xhigh" })).events); + const after = await collect(p.runTurn(turnOpts("echo-thinking", { effort: "xhigh", model: "openai/gpt-5" })).events); + expect(after.find((e) => e.type === "text_done")).toMatchObject({ content: "thinking:xhigh" }); + await p.teardown(); + }); +}); diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index 97e1ac4c..fb1fce30 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -12,7 +12,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseClientMessage } from "@highflame/codeoid-protocol/schemas"; import type { CodeoidConfig } from "../config.js"; -import { applyGeminiCliEffort, geminiEffortSettings } from "../daemon/account-apply.js"; +import { applyGeminiCliEffort, applyPiSettingsOverlay, geminiEffortSettings, piDefaultThinkingLevel } from "../daemon/account-apply.js"; +import { AccountStore } from "../daemon/accounts.js"; import { geminiThinkingConfig } from "../daemon/providers/gemini/index.js"; import { llamaCppEffortParams } from "../daemon/providers/llamacpp/index.js"; import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; @@ -96,6 +97,47 @@ describe("each backend's reading of the scale", () => { }); }); +describe("pi's settings overlay", () => { + it("links the user's logins — made if missing, as pi would — and copies only settings.json; locks never linked", () => { + const base = join(tmp, "pi"); + mkdirSync(join(base, "auth.json.lock"), { recursive: true }); // another pi holds the lock right now + writeFileSync(join(base, "settings.json"), JSON.stringify({ defaultThinkingLevel: "high" })); + const homes = join(tmp, "homes"); + const home = applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: base }, homes).PI_CODING_AGENT_DIR!; + expect(readlinkSync(join(home, "auth.json"))).toBe(join(base, "auth.json")); // made in the base, then linked + expect(lstatSync(join(home, "settings.json")).isSymbolicLink()).toBe(false); + expect(() => lstatSync(join(home, "auth.json.lock"))).toThrow(); + expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: base })).toBe("high"); + // An overlay that took an empty auth.json of its own before gets the user's linked instead. + rmSync(join(home, "auth.json")); + writeFileSync(join(home, "auth.json"), "{}"); + applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: base }, homes); + expect(lstatSync(join(home, "auth.json")).isSymbolicLink()).toBe(true); + }); + + it("reads a ~ path as pi does", () => { + mkdirSync(join(tmp, "h", "x"), { recursive: true }); + writeFileSync(join(tmp, "h", "x", "settings.json"), JSON.stringify({ defaultThinkingLevel: "minimal" })); + expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: "~/x", HOME: join(tmp, "h") })).toBe("minimal"); + expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: join(tmp, "none") })).toBe("medium"); + }); + + it("an account's overlays go with the account", () => { + const homes = join(tmp, "homes"); + const accounts = new AccountStore({ path: join(tmp, "accounts.json"), homesRoot: homes, legacyClaudePath: null }); + accounts.upsert({ backend: "pi", name: "work", kind: "api-keys", env: { OPENAI_API_KEY: "sk-test" } } as never); + const home = accounts.homeOf("pi", "work"); + mkdirSync(home, { recursive: true }); + const piOverlay = applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: home }, homes).PI_CODING_AGENT_DIR!; + mkdirSync(join(home, ".gemini"), { recursive: true }); + const geminiOverlay = applyGeminiCliEffort("high", { GEMINI_CLI_HOME: home }, homes).GEMINI_CLI_HOME!; + accounts.setDefault("pi", "default"); // a backend's default can't be removed + expect(accounts.remove("pi", "work")).toBe(true); + expect(() => lstatSync(piOverlay)).toThrow(); + expect(() => lstatSync(geminiOverlay)).toThrow(); + }); +}); + // ── through the session manager ────────────────────────────────────────────── const ALL = [SCOPES.SESSION_CREATE, SCOPES.SESSION_SEND, SCOPES.SESSION_LIST]; From 82f10cba6e797baa00e00b169c034396d074505f Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 15:48:23 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20#417=20round=203=20=E2=80=94=20Cod?= =?UTF-8?q?ex's=20baseline=20read=20per=20thread=20and=20fork;=20only=20lo?= =?UTF-8?q?ck=20directories=20kept=20out=20of=20overlays;=20pi=20Default?= =?UTF-8?q?=20reads=20project=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex: the configured effort is read again for each thread (start, resume, fork, inject), as codex reads its config; the fork path records the thread's model; a turn that names a model moves the baseline to it. - Overlays skip only a lock DIRECTORY (proper-lockfile's, held right now); a lock file such as codex's .sqlite-maintenance.lock is shared again. - pi: making auth.json tolerates a dangling link or one made meanwhile; Default reads the project's .pi/settings.json first, as pi layers it; an account overlay's source expands ~ too. - Docs: pi's OAuth refresh does share the lock (only sign-in/out doesn't); Codex's Default for a model its catalog doesn't list. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 5 +-- src/daemon/account-apply.ts | 53 ++++++++++++++++++++--------- src/daemon/providers/codex/index.ts | 10 +++++- src/daemon/providers/pi/index.ts | 6 ++-- src/tests/provider-codex.test.ts | 9 +++++ src/tests/thinking-effort.test.ts | 22 +++++++++++- 6 files changed, 82 insertions(+), 23 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index f7571dc7..6e823518 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -7,7 +7,8 @@ How hard a session's model thinks before it answers, on one scale for every back **Default, Off, Minimal, Low, Medium, High, Extra high (`xhigh`), Max.** **Default** is the backend's own choice, as it always was. -Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (project config included), else the running model's own default; pi gets the default in your pi settings (`defaultThinkingLevel`, else Medium). +Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (project config included), else the running model's own default; pi gets the default in its settings (the project's `.pi/settings.json`, else yours; `defaultThinkingLevel`, else Medium). +On Codex, a model its catalog doesn't list, with no effort configured, has no known default: Default then sends nothing, so a level set earlier on that thread stays until the next thread. Every other level is mapped to the backend's own setting (below). A level a backend has no exact match for goes to its nearest one; pi clamps a level a model doesn't take to the nearest it does. @@ -66,6 +67,6 @@ Notes: A template that reads neither ignores them. - **pi** saves a thinking-level change as your global default in its settings, so codeoid runs it from an overlay of its agent directory: your logins and sessions are linked, and `settings.json` is a fresh copy at each start, so one session's effort never changes your own pi or other sessions. The overlays live under `~/.codeoid/accounts/pi-session/`. - One limit: pi locks `auth.json` by the path it was given, so a pi you run yourself on the same login doesn't share codeoid's lock; in the rare case both refresh an expiring OAuth login at the same moment, one may have to sign in again. + pi's OAuth refresh locks the real `auth.json`, so codeoid's pi and one you run yourself refresh in turn; only a sign-in or sign-out made in both at the same moment isn't serialized. - The Gemini CLI and pi overlays of an account's home are removed with the account. - **A project's own Gemini CLI settings** (`.gemini/settings.json` in the workdir) outrank your user settings, so a project that redefines `chat-base-2.5` or `chat-base-3` there overrides the effort on the Gemini CLI. diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index ed529387..b4e96a81 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -12,7 +12,7 @@ */ import { spawn } from "node:child_process"; -import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { atomicWrite, getConfigDir } from "../config.js"; @@ -59,7 +59,7 @@ export function ensureOverlayHome(spec: OverlaySpec): void { // No default home yet: the account starts from nothing but its own files. } for (const name of entries) { - if (own.has(name) || name in copies || isLockEntry(name)) continue; + if (own.has(name) || name in copies || isLockEntry(source, name)) continue; const link = join(spec.target, name); const want = join(source, name); let stat: ReturnType | null = null; @@ -91,7 +91,7 @@ export function ensureOverlayHome(spec: OverlaySpec): void { continue; } if (!isLink) continue; - if (own.has(name) || name in copies || isLockEntry(name) || !entries.includes(name)) unlinkLink(link); + if (own.has(name) || name in copies || isLockEntry(source, name) || !entries.includes(name)) unlinkLink(link); } for (const [name, transform] of Object.entries(copies)) { const from = join(source, name); @@ -361,9 +361,18 @@ function piAgentDirOf(env: Record): string { return dir === "~" || dir.startsWith("~/") ? join(env.HOME || homedir(), dir.slice(1)) : dir; } -/** A lock another process holds right now (proper-lockfile's `.lock`): never linked into an overlay. */ -function isLockEntry(name: string): boolean { - return name.endsWith(".lock"); +/** + * A lock another process holds right now — proper-lockfile's `.lock` + * directory (pi, the Gemini CLI) — never linked into an overlay. A lock FILE + * (codex's .sqlite-maintenance.lock) is a permanent shared lock: linked as before. + */ +function isLockEntry(dir: string, name: string): boolean { + if (!name.endsWith(".lock")) return false; + try { + return statSync(join(dir, name)).isDirectory(); + } catch { + return false; + } } /** Remove a link that another session building the same overlay may have removed first. */ @@ -394,7 +403,14 @@ export function applyPiSettingsOverlay( // not a file of the overlay's own that a later `pi /login` never reaches. mkdirSync(base, { recursive: true, mode: 0o700 }); const auth = join(base, "auth.json"); - if (!existsSync(auth)) writeFileSync(auth, "{}", { mode: 0o600, flag: "wx" }); + if (!existsSync(auth)) { + try { + writeFileSync(auth, "{}", { mode: 0o600, flag: "wx" }); // never through a planted link + } catch (err) { + // There after all (made meanwhile, or a dangling link): pi's to deal with, as without codeoid. + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + } const home = join(homesRoot, "pi-session", overlayKey(base)); // An overlay built before that took an empty one of its own: link the user's instead. try { @@ -413,14 +429,19 @@ export function applyPiSettingsOverlay( return { ...env, PI_CODING_AGENT_DIR: home }; } -/** The thinking level pi starts a session at, as the user set it (#417): their settings' default, else pi's, Medium. */ -export function piDefaultThinkingLevel(env: Record): string { - const base = piAgentDirOf(env); - try { - const level = (JSON.parse(readFileSync(join(base, "settings.json"), "utf8")) as { defaultThinkingLevel?: unknown }).defaultThinkingLevel; - if (typeof level === "string" && level) return level; - } catch { - // None, or unreadable: pi's own default. +/** + * The thinking level pi starts a session at, as the user set it (#417): the + * project's .pi/settings.json default (pi layers it over the user's), else + * the user's, else pi's own, Medium. + */ +export function piDefaultThinkingLevel(env: Record, workdir?: string): string { + for (const file of [...(workdir ? [join(workdir, ".pi", "settings.json")] : []), join(piAgentDirOf(env), "settings.json")]) { + try { + const level = (JSON.parse(readFileSync(file, "utf8")) as { defaultThinkingLevel?: unknown }).defaultThinkingLevel; + if (typeof level === "string" && level) return level; + } catch { + // None, or unreadable: the next. + } } return "medium"; } @@ -432,7 +453,7 @@ export function piDefaultThinkingLevel(env: Record): */ export function applyPiAccount(account: ResolvedAccount | null, env: Record): Record { if (!account) return env; - ensureOverlayHome({ source: defaultPiAgentDir(), target: account.home, own: ["auth.json"], shared: ["sessions"] }); + ensureOverlayHome({ source: piAgentDirOf(process.env), target: account.home, own: ["auth.json"], shared: ["sessions"] }); const out = without( env, (k) => k.endsWith("_API_KEY") || k.endsWith("_API_TOKEN") || k === "PI_CODING_AGENT_DIR" || /^(ANTHROPIC|OPENAI|GOOGLE|GEMINI)_/.test(k), diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index fc86a5ca..37441232 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -469,13 +469,16 @@ export class CodexProvider implements SessionProvider { // Don't stream the whole thread back over stdio; only its id is used. excludeTurns: true, ...config, - })) as { thread?: { id?: string } }; + })) as { thread?: { id?: string }; model?: string }; if (!forked.thread?.id) throw new Error("thread/fork returned no thread id"); + this.#threadModel = forked.model ?? null; + this.#configEffort = undefined; return forked.thread.id; } const started = (await this.#proc.request("thread/start", config)) as { thread?: { id?: string }; model?: string }; if (!started.thread?.id) throw new Error("thread/start returned no thread id"); this.#threadModel = started.model ?? null; + this.#configEffort = undefined; await this.#proc.request("thread/inject_items", { threadId: started.thread.id, items: pending.items }); return started.thread.id; } catch (err) { @@ -753,6 +756,8 @@ export class CodexProvider implements SessionProvider { // value codex picks unasked; sending it resets an earlier override). ...(await this.#effortParam(opts)), })) as { turn?: { id?: string } }; + // codex keeps a turn's model for the turns after it: Default's baseline follows it. + if (opts.model) this.#threadModel = opts.model; this.#currentTurnId = result.turn?.id ?? null; } @@ -826,6 +831,7 @@ export class CodexProvider implements SessionProvider { if (resumed.thread?.id) { this.#threadId = resumed.thread.id; this.#threadModel = resumed.model ?? null; + this.#configEffort = undefined; return; } } catch { @@ -844,6 +850,8 @@ export class CodexProvider implements SessionProvider { const started = (await this.#proc.request("thread/start", threadConfig(opts))) as { thread?: { id?: string }; model?: string }; if (!started.thread?.id) throw new Error("codex thread/start returned no thread id"); this.#threadModel = started.model ?? null; + // codex reads its config afresh for each thread; so does Default's baseline. + this.#configEffort = undefined; this.#adoptThread(started.thread.id); await this.#reportModelsOnce(); } diff --git a/src/daemon/providers/pi/index.ts b/src/daemon/providers/pi/index.ts index 5c6cb7b6..94ba3719 100644 --- a/src/daemon/providers/pi/index.ts +++ b/src/daemon/providers/pi/index.ts @@ -330,7 +330,7 @@ export class PiProvider implements SessionProvider { // Allowlisted env (GHSA-38vh): pi gets its credentials (~/.pi via // HOME, provider API keys), never codeoid's own secrets. // pi's thinking-level writes stay in a copy of its settings (#417). - env: this.#piEnv(), + env: this.#piEnv(opts.workdir), onEvent: (frame) => this.#onFrame(frame), onExit: ({ code, signal, stderrTail }) => { this.#bridgeReady = false; @@ -429,9 +429,9 @@ export class PiProvider implements SessionProvider { } /** pi's environment: the account's, in an overlay whose settings.json is codeoid's copy. */ - #piEnv(): Record { + #piEnv(workdir: string): Record { const env = applyPiAccount(this.#resolveAccount?.() ?? null, buildPiEnv()); - this.#defaultThinkingLevel = piDefaultThinkingLevel(env); + this.#defaultThinkingLevel = piDefaultThinkingLevel(env, workdir); return applyPiSettingsOverlay(env); } diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts index cc1ec026..1fa1eaff 100644 --- a/src/tests/provider-codex.test.ts +++ b/src/tests/provider-codex.test.ts @@ -1312,6 +1312,15 @@ describe("CodexProvider – thinking effort (#417)", () => { process.env.CODEX_FAKE_CONFIG_EFFORT = "xhigh"; try { expect(await effortSeen()).toBe("xhigh"); // the user's model_reasoning_effort wins + // Read afresh for each thread, as codex does: an edit made meanwhile counts. + const p = makeProvider(); + await collect(p.runTurn(turnOpts("echo-effort"))); + await p.teardown(); + await Bun.sleep(100); // the killed app-server's exit lands before the next turn starts + process.env.CODEX_FAKE_CONFIG_EFFORT = "medium"; + const events = await collect(p.runTurn(turnOpts("echo-effort"))); + await p.teardown(); + expect(JSON.parse((events.find((e) => e.type === "text_done") as { content: string }).content).effort).toBe("medium"); } finally { if (saved === undefined) delete process.env.CODEX_FAKE_CONFIG_EFFORT; else process.env.CODEX_FAKE_CONFIG_EFFORT = saved; diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index fb1fce30..a0e162cf 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { Database } from "bun:sqlite"; -import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseClientMessage } from "@highflame/codeoid-protocol/schemas"; @@ -115,6 +115,26 @@ describe("pi's settings overlay", () => { expect(lstatSync(join(home, "auth.json")).isSymbolicLink()).toBe(true); }); + it("a dangling auth.json link is left to pi; a permanent lock FILE (codex's) is still linked", () => { + const base = join(tmp, "pi2"); + mkdirSync(base, { recursive: true }); + symlinkSync(join(tmp, "nowhere", "auth.json"), join(base, "auth.json")); + writeFileSync(join(base, ".sqlite-maintenance.lock"), ""); + const home = applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: base }, join(tmp, "homes")).PI_CODING_AGENT_DIR!; + expect(readlinkSync(join(home, ".sqlite-maintenance.lock"))).toBe(join(base, ".sqlite-maintenance.lock")); + }); + + it("pi's Default: the project's settings first, then the user's", () => { + const agent = join(tmp, "agent"); + const work = join(tmp, "proj"); + mkdirSync(join(work, ".pi"), { recursive: true }); + mkdirSync(agent, { recursive: true }); + writeFileSync(join(agent, "settings.json"), JSON.stringify({ defaultThinkingLevel: "low" })); + expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: agent }, work)).toBe("low"); + writeFileSync(join(work, ".pi", "settings.json"), JSON.stringify({ defaultThinkingLevel: "high" })); + expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: agent }, work)).toBe("high"); + }); + it("reads a ~ path as pi does", () => { mkdirSync(join(tmp, "h", "x"), { recursive: true }); writeFileSync(join(tmp, "h", "x", "settings.json"), JSON.stringify({ defaultThinkingLevel: "minimal" })); From 1c3fe7104d7ede4129b4672b60912d8c567a17c6 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 16:00:18 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20#417=20round=204=20=E2=80=94=20pi'?= =?UTF-8?q?s=20Default=20never=20takes=20a=20repo's=20settings;=20a=20fork?= =?UTF-8?q?=20at=20Default=20stays=20at=20Default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pi: Default is the user's own defaultThinkingLevel; a project's .pi/settings.json is no longer read — pi honours it only for a project the user trusts, and a repo mustn't choose the level (and the cost). - A fork of a session at Default stays at Default rather than taking session.defaultEffort (an explicit null at creation is kept). - An overlay entry named .lock that vanishes between the listing and the check is a lock just released: not linked. - Wording: the Gemini CLI restarts between turns once no work is running; pi's lock note in the code matches the docs. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 5 +++-- src/daemon/account-apply.ts | 27 +++++++++++++-------------- src/daemon/providers/pi/index.ts | 6 +++--- src/daemon/session-manager.ts | 2 +- src/daemon/session.ts | 10 +++++++--- src/tests/thinking-effort.test.ts | 18 ++++++++++++++---- 6 files changed, 41 insertions(+), 27 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index 6e823518..5b56db5c 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -7,7 +7,7 @@ How hard a session's model thinks before it answers, on one scale for every back **Default, Off, Minimal, Low, Medium, High, Extra high (`xhigh`), Max.** **Default** is the backend's own choice, as it always was. -Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (project config included), else the running model's own default; pi gets the default in its settings (the project's `.pi/settings.json`, else yours; `defaultThinkingLevel`, else Medium). +Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (project config included), else the running model's own default; pi gets the default in your pi settings (`defaultThinkingLevel`, else Medium; a project's `.pi/settings.json` isn't read, since pi honours it only for a project you trust). On Codex, a model its catalog doesn't list, with no effort configured, has no known default: Default then sends nothing, so a level set earlier on that thread stays until the next thread. Every other level is mapped to the backend's own setting (below). A level a backend has no exact match for goes to its nearest one; pi clamps a level a model doesn't take to the nearest it does. @@ -41,7 +41,8 @@ A change never cuts a running turn short. - **Now**, on the running backend: Claude (except to or from Off), Qwen (except back to Default), pi. - **From the next turn**: Codex, OpenAI, llama.cpp, Gemini — they take it with every request — and the exceptions above, whose next turn starts a fresh query that resumes the conversation. -- **After a restart, before the next turn**: the Gemini CLI, which reads it only from its settings at startup. It resumes the conversation, as after a model change. +- **After a restart, between turns**: the Gemini CLI, which reads it only from its settings at startup. + It restarts before the next turn that starts with no background work running, and resumes the conversation, as after a model change. ## What each level means on each backend diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index b4e96a81..f4de8b47 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -371,7 +371,7 @@ function isLockEntry(dir: string, name: string): boolean { try { return statSync(join(dir, name)).isDirectory(); } catch { - return false; + return true; // gone between the listing and now: a lock just released } } @@ -391,8 +391,8 @@ function unlinkLink(link: string): void { * pi saves a thinking-level change (and a model change) as the user's global * default in that file; the copy keeps them out of the user's own pi. (Sessions * on the same base share the copy — codeoid sends each its level every turn.) - * pi locks auth.json by the path it was given, so a pi run elsewhere on the - * user's own dir doesn't share that lock (docs/thinking-effort.md). + * pi's OAuth refresh locks the real auth.json; only a sign-in or sign-out + * locks by this overlay's path (docs/thinking-effort.md). */ export function applyPiSettingsOverlay( env: Record, @@ -430,18 +430,17 @@ export function applyPiSettingsOverlay( } /** - * The thinking level pi starts a session at, as the user set it (#417): the - * project's .pi/settings.json default (pi layers it over the user's), else - * the user's, else pi's own, Medium. + * The thinking level pi starts a session at, as the user set it (#417): their + * settings' default, else pi's own, Medium. A project's .pi/settings.json + * isn't read: pi honours it only for a project the user trusts, and a repo + * mustn't choose the level (and the cost) by committing one. */ -export function piDefaultThinkingLevel(env: Record, workdir?: string): string { - for (const file of [...(workdir ? [join(workdir, ".pi", "settings.json")] : []), join(piAgentDirOf(env), "settings.json")]) { - try { - const level = (JSON.parse(readFileSync(file, "utf8")) as { defaultThinkingLevel?: unknown }).defaultThinkingLevel; - if (typeof level === "string" && level) return level; - } catch { - // None, or unreadable: the next. - } +export function piDefaultThinkingLevel(env: Record): string { + try { + const level = (JSON.parse(readFileSync(join(piAgentDirOf(env), "settings.json"), "utf8")) as { defaultThinkingLevel?: unknown }).defaultThinkingLevel; + if (typeof level === "string" && level) return level; + } catch { + // None, or unreadable: pi's own default. } return "medium"; } diff --git a/src/daemon/providers/pi/index.ts b/src/daemon/providers/pi/index.ts index 94ba3719..5c6cb7b6 100644 --- a/src/daemon/providers/pi/index.ts +++ b/src/daemon/providers/pi/index.ts @@ -330,7 +330,7 @@ export class PiProvider implements SessionProvider { // Allowlisted env (GHSA-38vh): pi gets its credentials (~/.pi via // HOME, provider API keys), never codeoid's own secrets. // pi's thinking-level writes stay in a copy of its settings (#417). - env: this.#piEnv(opts.workdir), + env: this.#piEnv(), onEvent: (frame) => this.#onFrame(frame), onExit: ({ code, signal, stderrTail }) => { this.#bridgeReady = false; @@ -429,9 +429,9 @@ export class PiProvider implements SessionProvider { } /** pi's environment: the account's, in an overlay whose settings.json is codeoid's copy. */ - #piEnv(workdir: string): Record { + #piEnv(): Record { const env = applyPiAccount(this.#resolveAccount?.() ?? null, buildPiEnv()); - this.#defaultThinkingLevel = piDefaultThinkingLevel(env, workdir); + this.#defaultThinkingLevel = piDefaultThinkingLevel(env); return applyPiSettingsOverlay(env); } diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 34d607a5..a73f5ab5 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -4779,7 +4779,7 @@ mcpHub: this.#mcpHub, // thinking as hard (#417). claudeAccount: parent.claudeAccount, accountChoices: parent.accountChoices, - ...(parentInfo.effort ? { effort: parentInfo.effort } : {}), + effort: parentInfo.effort ?? null, // the parent's Default stays Default hooks: this.#hooks, providerId, forkedFrom: { diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 895b7610..efbee8fc 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -424,7 +424,7 @@ export interface SessionCreateOptions { * Thinking effort chosen at creation (#417): outranks * config.session.defaultEffort, and is kept with the session. */ - effort?: EffortLevel; + effort?: EffortLevel | null; /** * Observe every status transition of this session. The dispatcher uses * this to detect a worker's turn completing (→ idle/error) or wedging @@ -1310,7 +1310,11 @@ export class Session { // Thinking effort (#417): a resumed session keeps its own (none = the // backend's default); a new one takes the effort chosen at creation, else // the config default. - this.#effort = opts.existingId ? this.#store.getSessionEffort(this.id) : (opts.effort ?? opts.config?.session.defaultEffort ?? null); + this.#effort = opts.existingId + ? this.#store.getSessionEffort(this.id) + : opts.effort !== undefined + ? opts.effort + : (opts.config?.session.defaultEffort ?? null); this.#provider = opts._testProvider ?? this.#createProvider(opts.providerId); this.#appliedAccount = this.#accountState(this.#provider.id); @@ -5089,7 +5093,7 @@ export class Session { } } const shown = effort ?? "the backend's default"; - const when = applies === "now" ? "" : applies === "restart" ? " — the backend restarts with it before the next turn" : " — from the next turn"; + const when = applies === "now" ? "" : applies === "restart" ? " — the backend restarts with it between turns, once no work is running" : " — from the next turn"; const info = this.#makeMessage("info", `⎆ Thinking effort: ${shown}${when}.`, SYSTEM_IDENTITY, undefined, undefined, { event: "session.effort_changed", effort, diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index a0e162cf..2494d8ff 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -124,15 +124,14 @@ describe("pi's settings overlay", () => { expect(readlinkSync(join(home, ".sqlite-maintenance.lock"))).toBe(join(base, ".sqlite-maintenance.lock")); }); - it("pi's Default: the project's settings first, then the user's", () => { + it("pi's Default is the user's own — never a repo's .pi/settings.json, which pi honours only for a trusted project", () => { const agent = join(tmp, "agent"); const work = join(tmp, "proj"); mkdirSync(join(work, ".pi"), { recursive: true }); mkdirSync(agent, { recursive: true }); writeFileSync(join(agent, "settings.json"), JSON.stringify({ defaultThinkingLevel: "low" })); - expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: agent }, work)).toBe("low"); - writeFileSync(join(work, ".pi", "settings.json"), JSON.stringify({ defaultThinkingLevel: "high" })); - expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: agent }, work)).toBe("high"); + writeFileSync(join(work, ".pi", "settings.json"), JSON.stringify({ defaultThinkingLevel: "xhigh" })); + expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: agent })).toBe("low"); }); it("reads a ~ path as pi does", () => { @@ -289,6 +288,17 @@ describe("a session's thinking effort", () => { expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "restart" } }); }); + it("a fork of a session at Default stays at Default, whatever the config default", async () => { + const { m } = manager(() => new MockSessionProvider("claude", []), "claude", "high"); + const sid = await create(m); + expect(await setEffort(m, sid, null)).toMatchObject({ type: "response.ok" }); + await turn(m, sid); + const r = (await m.handle({ type: "session.fork", id: "f", sessionId: sid, name: "f" }, auth([...ALL, SCOPES.SESSION_READ]), client)) as { + data?: { id?: string; session?: { id: string } }; + }; + expect(m._sessionForTest((r.data?.session?.id ?? r.data?.id)!)!.toInfo().effort).toBeUndefined(); + }); + it("a fork thinks as hard as the session it came from", async () => { const { m } = manager(() => new MockSessionProvider("claude", [])); const sid = await create(m, "xhigh"); From df023b577a92307611b8b20f4e88552a46a2529e Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 21:40:05 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20#417=20round=205=20=E2=80=94=20Cla?= =?UTF-8?q?ude's=20Default=20reads=20the=20user's=20own=20Claude=20setting?= =?UTF-8?q?s=20again;=20Codex=20marks=20the=20model=20it=20runs=20unasked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Claude: back to Default rebuilds the query before the next turn instead of a live "no level", which the SDK takes as the model's default rather than the user's own Claude Code effort setting. - Codex: the catalog marks the model codex runs when none is chosen (the configured one, else its own default), so a picker shows its levels. - session.create takes effort: null for the backend's default (and `codeoid new --effort default` sends it), rather than falling to session.defaultEffort. - The secret-path guard reads PI_CODING_AGENT_DIR's ~ as pi does. - Claude's reported levels are kept to codeoid's scale. - Wording: the restart message every frontend shows; docs on what each backend reads at Default (Qwen's project settings, Claude's own). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 3 ++- packages/core/src/slash.ts | 2 +- packages/protocol/src/schemas.ts | 2 +- packages/protocol/src/types.ts | 4 ++-- src/cli.ts | 6 +++--- src/daemon/account-apply.ts | 2 +- src/daemon/providers/claude/index.ts | 22 ++++++++++++---------- src/daemon/providers/codex/index.ts | 15 ++++++++++++++- src/daemon/providers/interface.ts | 4 ++++ src/daemon/secret-paths.ts | 4 ++-- src/daemon/session-manager.ts | 4 ++-- src/daemon/session.ts | 6 +++--- src/terminal/client.ts | 4 ++-- src/tests/fixtures/fake-codex.ts | 9 ++++++++- src/tests/provider-claude.test.ts | 8 +++++--- src/tests/provider-codex.test.ts | 24 ++++++++++++++++++++++++ src/tests/secret-paths.test.ts | 14 ++++++++++++++ src/tests/thinking-effort.test.ts | 8 ++++++++ 18 files changed, 108 insertions(+), 33 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index 5b56db5c..c36c10b9 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -39,7 +39,7 @@ A level the server refuses fails the turn with a note saying so; pick another, o A change never cuts a running turn short. -- **Now**, on the running backend: Claude (except to or from Off), Qwen (except back to Default), pi. +- **Now**, on the running backend: Claude (except to or from Off, or back to Default), Qwen (except back to Default), pi. - **From the next turn**: Codex, OpenAI, llama.cpp, Gemini — they take it with every request — and the exceptions above, whose next turn starts a fresh query that resumes the conversation. - **After a restart, between turns**: the Gemini CLI, which reads it only from its settings at startup. It restarts before the next turn that starts with no background work running, and resumes the conversation, as after a model change. @@ -70,4 +70,5 @@ Notes: The overlays live under `~/.codeoid/accounts/pi-session/`. pi's OAuth refresh locks the real `auth.json`, so codeoid's pi and one you run yourself refresh in turn; only a sign-in or sign-out made in both at the same moment isn't serialized. - The Gemini CLI and pi overlays of an account's home are removed with the account. +- **At Default**, a backend reads its own config as it always did: Qwen applies a project's `.qwen/settings.json` reasoning effort, Claude your Claude Code effort setting. A level you set overrides them. - **A project's own Gemini CLI settings** (`.gemini/settings.json` in the workdir) outrank your user settings, so a project that redefines `chat-base-2.5` or `chat-base-3` there overrides the effort on the Gemini CLI. diff --git a/packages/core/src/slash.ts b/packages/core/src/slash.ts index 48b1fa4d..f6d513ab 100644 --- a/packages/core/src/slash.ts +++ b/packages/core/src/slash.ts @@ -456,6 +456,6 @@ export function parseEffort(arg: string): EffortLevel | null { export function effortChangeSummary(r: EffortChangeResult): string { const what = r.effort ? EFFORT_LABELS[r.effort] : "the backend's default"; if (r.applies === "now") return `Thinking effort: ${what}.`; - if (r.applies === "restart") return `Thinking effort: ${what} — the backend restarts with it before your next message.`; + if (r.applies === "restart") return `Thinking effort: ${what} — the backend restarts with it between turns, once no work is running.`; return `Thinking effort: ${what}, from the next turn.`; } diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 382767f7..d36d1c68 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -128,7 +128,7 @@ export const sessionCreateSchema = z.object({ */ model: z.string().min(1).max(LIMITS.MODEL_MAX).optional(), /** Thinking effort (#417). */ - effort: z.enum(EFFORT_LEVELS).optional(), + effort: z.enum(EFFORT_LEVELS).nullable().optional(), /** * Activate an installed SDLC pack on this session (ambient mode — * docs/pack-loading.md): its constitution is injected into the system diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 0b064da7..14baaffb 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -1329,8 +1329,8 @@ export interface SessionCreateMsg extends BaseClientMsg { * absent otherwise = the provider's default. */ model?: string; - /** Thinking effort (#417). Absent = `session.defaultEffort`, else the backend's default. */ - effort?: EffortLevel; + /** Thinking effort (#417). Absent = `session.defaultEffort`, else the backend's default; null = the backend's default. */ + effort?: EffortLevel | null; /** * Activate an installed SDLC pack on this session (ambient mode): inject its * constitution, expose its skills/subagents, and — with `packRole` — run diff --git a/src/cli.ts b/src/cli.ts index fb2548d9..07b59345 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1071,10 +1071,10 @@ program process.exit(1); } - let effort: EffortLevel | undefined; + let effort: EffortLevel | null | undefined; if (opts.effort) { try { - effort = parseEffort(opts.effort) ?? undefined; + effort = parseEffort(opts.effort); // "default" = the backend's own, not session.defaultEffort } catch (e) { console.error(`--effort: ${e instanceof Error ? e.message : String(e)}`); process.exit(1); @@ -1085,7 +1085,7 @@ program await client.createSession(name, resolvedWorkdir, { providerId: opts.provider, model: opts.model, - ...(effort ? { effort } : {}), + ...(effort !== undefined ? { effort } : {}), account: opts.account, pack: opts.pack, packRole: opts.packRole, diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index f4de8b47..f8eb09d4 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -356,7 +356,7 @@ export function defaultPiAgentDir(env: Record = proc } /** pi's agent dir, as pi reads PI_CODING_AGENT_DIR (a leading ~ is home). */ -function piAgentDirOf(env: Record): string { +export function piAgentDirOf(env: Record): string { const dir = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); return dir === "~" || dir.startsWith("~/") ? join(env.HOME || homedir(), dir.slice(1)) : dir; } diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 2eea6222..f7366ca5 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -47,7 +47,7 @@ import { FLEET_TOOL_NAMES } from "../../fleet.js"; import { rewriteBashToolInput } from "../../compress/index.js"; import { type CodeoidConfig, getConfigDir } from "../../../config.js"; import { secretPathVerdict } from "../../secret-paths.js"; -import type { AuthContext, EffortLevel } from "../../../protocol/types.js"; +import { type AuthContext, EFFORT_LEVELS, type EffortLevel } from "../../../protocol/types.js"; import type { BackendMcpServerState, SessionProvider, @@ -625,16 +625,18 @@ export class ClaudeProvider implements SessionProvider { /** * A new thinking effort on the running query, live (#417) — except to or - * from Off, which the SDK fixes when the query is built: that one rebuilds - * the query before the next turn (it resumes the same conversation). + * from Off, which the SDK fixes when the query is built, and back to the + * default: the SDK's live "no level" is the model's default, not the user's + * own Claude settings, which only a fresh query reads. Those rebuild the + * query before the next turn (it resumes the same conversation). */ async applyEffort(effort: EffortLevel | null): Promise { const query$ = this.#query; - if (!query$ || !this.#consumerTask || effort === "off" || this.#builtEffort === "off") return false; - await query$.applyFlagSettings({ effortLevel: effort ? claudeEffort(effort) : null }); - this.#builtEffort = effort ?? ""; + if (!query$ || !this.#consumerTask || effort === null || effort === "off" || this.#builtEffort === "off") return false; + await query$.applyFlagSettings({ effortLevel: claudeEffort(effort) }); + this.#builtEffort = effort; // A retry or recovery later in this turn rebuilds from these: at the new effort. - if (this.#lastTurnOpts) this.#lastTurnOpts = { ...this.#lastTurnOpts, effort: effort ?? undefined }; + if (this.#lastTurnOpts) this.#lastTurnOpts = { ...this.#lastTurnOpts, effort }; return true; } @@ -2260,12 +2262,12 @@ export function claudeCatalogEntry(m: { displayName: string; description?: string; supportsEffort?: boolean; - supportedEffortLevels?: ("low" | "medium" | "high" | "xhigh" | "max")[]; + supportedEffortLevels?: string[]; }): CatalogEntry { // Off (thinking disabled) is always there; the levels are the model's own. // A model that doesn't say which levels it takes leaves them unknown. - const efforts: EffortLevel[] | undefined = - m.supportsEffort === false ? ["off"] : m.supportedEffortLevels ? ["off", ...m.supportedEffortLevels] : undefined; + const levels = m.supportedEffortLevels?.filter((l): l is EffortLevel => (EFFORT_LEVELS as readonly string[]).includes(l)); + const efforts: EffortLevel[] | undefined = m.supportsEffort === false ? ["off"] : levels ? ["off", ...levels] : undefined; return { value: m.value, displayName: m.displayName, diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index 37441232..b43116b9 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -661,6 +661,7 @@ export class CodexProvider implements SessionProvider { description?: string; supportedReasoningEfforts?: Array<{ reasoningEffort?: string }>; defaultReasoningEffort?: string; + isDefault?: boolean; }>; }; // Each model's own default effort — what Default falls back to (#417). @@ -682,6 +683,7 @@ export class CodexProvider implements SessionProvider { description: m.description, ...(efforts ? { efforts } : {}), ...(defaultEffort ? { defaultEffort } : {}), + ...(m.isDefault === true ? { isDefault: true } : {}), }; }); } catch { @@ -878,7 +880,17 @@ export class CodexProvider implements SessionProvider { async #reportModelsOnce(): Promise { if (this.#modelsReported || !this.#onModels) return; - const models = await this.listModels(); + const listed = await this.listModels(); + // The model codex runs when none is chosen (#417): the user's configured + // one, else its catalog's default — so a picker shows that model's levels. + let configured: string | null = null; + try { + const read = (await this.#proc!.request("config/read", {})) as { config?: { model?: string | null } }; + configured = read.config?.model ?? null; + } catch { + // Unknown: the catalog's own default stands. + } + const models = configured && listed.some((m) => m.id === configured) ? listed.map((m) => ({ ...m, isDefault: m.id === configured })) : listed; if (models.length > 0) { this.#modelsReported = true; this.#onModels( @@ -888,6 +900,7 @@ export class CodexProvider implements SessionProvider { description: m.description, ...(m.efforts ? { efforts: m.efforts } : {}), ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), + ...(m.isDefault ? { isDefault: true } : {}), })), ); } diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index ea370971..ccfc8c96 100644 --- a/src/daemon/providers/interface.ts +++ b/src/daemon/providers/interface.ts @@ -511,6 +511,8 @@ export interface ModelInfo { /** Thinking efforts the model takes, when the backend reports them (#417). */ efforts?: EffortLevel[]; defaultEffort?: EffortLevel; + /** The model the backend runs when none is chosen. */ + isDefault?: boolean; } /** @@ -531,6 +533,8 @@ export interface CatalogEntry { efforts?: EffortLevel[]; /** The backend's default effort for the model, when it says. */ defaultEffort?: EffortLevel; + /** The model the backend runs when none is chosen, when it says (the effort picker reads its levels). */ + isDefault?: boolean; } /** diff --git a/src/daemon/secret-paths.ts b/src/daemon/secret-paths.ts index 292fb921..b8f4de5c 100644 --- a/src/daemon/secret-paths.ts +++ b/src/daemon/secret-paths.ts @@ -41,7 +41,7 @@ import { existsSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { defaultCodexHome, defaultGeminiCliRoot, defaultPiAgentDir } from "./account-apply.js"; +import { defaultCodexHome, defaultGeminiCliRoot, piAgentDirOf } from "./account-apply.js"; export type SecretPathVerdict = { action: "deny" | "ask"; path: string } | null; @@ -96,7 +96,7 @@ function cliCredentialFiles(): string[] { join(defaultGeminiCliRoot(), ".gemini", "oauth_creds.json"), join(defaultGeminiCliRoot(), ".gemini", "google_accounts.json"), join(defaultGeminiCliRoot(), ".gemini", "gemini-credentials.json"), - join(defaultPiAgentDir(), "auth.json"), + join(piAgentDirOf(process.env), "auth.json"), // ~ read as pi reads it ]; } diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index a73f5ab5..40bf01e1 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -1814,7 +1814,7 @@ mcpHub: this.#mcpHub, ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), ...(m.efforts ? { efforts: m.efforts } : {}), ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), - isDefault: m.value === "default", + isDefault: m.isDefault === true || m.value === "default", })); const previous = this.#modelsCache.get(providerId); if (previous && JSON.stringify(previous) === JSON.stringify(models)) return; @@ -4034,7 +4034,7 @@ mcpHub: this.#mcpHub, // Explicit `model`, or the pack role's resolved binding (§6.2). Outranks // config.session.defaultModel; still loses to a persisted choice. defaultModel, - ...(msg.effort ? { effort: msg.effort } : {}), + ...(msg.effort !== undefined ? { effort: msg.effort } : {}), pack, collaboration, identityManager: this.#identityManager, diff --git a/src/daemon/session.ts b/src/daemon/session.ts index efbee8fc..61f36114 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -916,7 +916,7 @@ export class Session { #fallbackModel: string | null = null; /** Thinking effort (#417), on codeoid's scale; null = the backend's default. Kept across a backend switch. */ #effort: EffortLevel | null = null; - /** A backend whose effort is a startup setting (the Gemini CLI) is rebuilt before the next turn. */ + /** A backend whose effort is a startup setting (the Gemini CLI) is rebuilt between turns, once no work is running. */ #effortRestartPending = false; // ── Auto-rotation (Layer D) ──────────────────────────────────────────── @@ -5066,8 +5066,8 @@ export class Session { * Set how hard the model thinks (#417); null = the backend's default. Kept * with the session. Never cuts a running turn short: applied live where * the backend can take it, otherwise from the next turn's options — or, on - * a backend that takes it only at startup, by rebuilding it before the next - * turn (it resumes the conversation). + * a backend that takes it only at startup, by rebuilding it between turns + * once no work is running (it resumes the conversation). */ async setEffort(effort: EffortLevel | null, sender: AuthContext): Promise { const prev = this.#effort; diff --git a/src/terminal/client.ts b/src/terminal/client.ts index 6c45ae21..8c998076 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -439,7 +439,7 @@ export class TerminalClient { claudeAccount?: string; /** Account for the backend the session lands on (accounts.ts). */ account?: string; - effort?: EffortLevel; + effort?: EffortLevel | null; collaboration?: CollaborationConfig; } = {}, ): Promise { @@ -453,7 +453,7 @@ export class TerminalClient { // a claude session. ...(opts.providerId ? { providerId: opts.providerId } : {}), ...(opts.model ? { model: opts.model } : {}), - ...(opts.effort ? { effort: opts.effort } : {}), + ...(opts.effort !== undefined ? { effort: opts.effort } : {}), ...(opts.claudeAccount ? { claudeAccount: opts.claudeAccount } : {}), ...(opts.account ? { account: opts.account } : {}), ...(opts.pack ? { pack: opts.pack } : {}), diff --git a/src/tests/fixtures/fake-codex.ts b/src/tests/fixtures/fake-codex.ts index 541de49d..a1753a14 100644 --- a/src/tests/fixtures/fake-codex.ts +++ b/src/tests/fixtures/fake-codex.ts @@ -422,7 +422,14 @@ async function handle(frame: Record): Promise { break; case "config/read": // The user's configured effort (#417), set by the test through the env. - send({ id, result: { config: { model_reasoning_effort: process.env.CODEX_FAKE_CONFIG_EFFORT || null }, origins: {}, layers: null } }); + send({ + id, + result: { + config: { model_reasoning_effort: process.env.CODEX_FAKE_CONFIG_EFFORT || null, model: process.env.CODEX_FAKE_CONFIG_MODEL || null }, + origins: {}, + layers: null, + }, + }); break; case "model/list": send({ diff --git a/src/tests/provider-claude.test.ts b/src/tests/provider-claude.test.ts index f2ffeadb..cc710fb0 100644 --- a/src/tests/provider-claude.test.ts +++ b/src/tests/provider-claude.test.ts @@ -2670,11 +2670,13 @@ describe("thinking effort", () => { for await (const e of first.events) if (e.type === "turn_done") break; expect(await provider.applyEffort("max")).toBe(true); expect(flagSettings).toEqual([{ effortLevel: "max" }]); - expect(await provider.applyEffort(null)).toBe(true); // back to the model's default - expect(flagSettings.at(-1)).toEqual({ effortLevel: null }); + // Back to the default isn't live: the SDK's live "no level" is the model's + // default, not the user's own Claude settings a fresh query reads. + expect(await provider.applyEffort(null)).toBe(false); expect(await provider.applyEffort("off")).toBe(false); // fixed at construction + expect(flagSettings).toEqual([{ effortLevel: "max" }]); // An effort the live query already runs at doesn't rebuild it... - void turn(provider); + void turn(provider, "max"); for (let i = 0; i < 20; i++) await Promise.resolve(); expect(queryCallCount).toBe(1); // ...Off does: a fresh query, with thinking disabled. diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts index 1fa1eaff..b88579df 100644 --- a/src/tests/provider-codex.test.ts +++ b/src/tests/provider-codex.test.ts @@ -1335,3 +1335,27 @@ describe("CodexProvider – thinking effort (#417)", () => { expect(models[0]).toMatchObject({ efforts: ["off", "low", "high"], defaultEffort: "low" }); }); }); + +describe("CodexProvider – the model it runs unasked (#417)", () => { + it("marks the user's configured model as the default in the catalog it reports, so a picker shows its levels", async () => { + const saved = process.env.CODEX_FAKE_CONFIG_MODEL; + process.env.CODEX_FAKE_CONFIG_MODEL = "gpt-5.6-terra"; + const reported: Array<{ value: string; isDefault?: boolean }> = []; + try { + const p = new CodexProvider({ + sessionId: "sess-d", + initialBackingId: "sess-d", + command: process.execPath, + argsPrefix: [FIXTURE], + store: {} as Store, + onModels: (models) => reported.push(...models.map((m) => ({ value: m.value, ...(m.isDefault ? { isDefault: true } : {}) }))), + }); + await collect(p.runTurn(turnOpts("hello"))); + await p.teardown(); + } finally { + if (saved === undefined) delete process.env.CODEX_FAKE_CONFIG_MODEL; + else process.env.CODEX_FAKE_CONFIG_MODEL = saved; + } + expect(reported).toEqual([{ value: "gpt-5.6-terra", isDefault: true }]); + }); +}); diff --git a/src/tests/secret-paths.test.ts b/src/tests/secret-paths.test.ts index c1be85e1..766e5601 100644 --- a/src/tests/secret-paths.test.ts +++ b/src/tests/secret-paths.test.ts @@ -55,6 +55,20 @@ describe("secretPathVerdict", () => { expect(verdict({ source: join(configDir, ".env"), destination: join(workdir, "x") }, "move_file")?.action).toBe("deny"); }); + test("denies pi's logins where pi reads them, a ~ in PI_CODING_AGENT_DIR included (#417)", () => { + const saved = { dir: process.env.PI_CODING_AGENT_DIR, home: process.env.HOME }; + process.env.HOME = join(root, "home"); + process.env.PI_CODING_AGENT_DIR = "~/pi-agent"; + try { + expect(verdict({ file_path: join(root, "home", "pi-agent", "auth.json") })?.action).toBe("deny"); + } finally { + for (const [k, v] of [["PI_CODING_AGENT_DIR", saved.dir], ["HOME", saved.home]] as const) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + }); + test("denies a process environment or memory", () => { for (const p of ["/proc/self/environ", "/proc/1234/environ", "/proc/self/mem", "/proc/self/fd/3", "/proc/self/task/9/environ"]) { expect(verdict({ file_path: p })?.action).toBe("deny"); diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index 2494d8ff..d970a716 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -288,6 +288,14 @@ describe("a session's thinking effort", () => { expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "restart" } }); }); + it("created at Default explicitly, a session takes the backend's default, not the config's", async () => { + const { m } = manager(() => new MockSessionProvider("claude", []), "claude", "max"); + mkdirSync(join(tmp, "work"), { recursive: true }); + const r = (await m.handle({ type: "session.create", id: "c", name: "d", workdir: join(tmp, "work"), effort: null }, auth(), client)) as { data: { id: string } }; + expect(m._sessionForTest(r.data.id)!.toInfo().effort).toBeUndefined(); + expect(parseClientMessage({ type: "session.create", id: "c", name: "d", workdir: "/w", effort: null }).ok).toBe(true); + }); + it("a fork of a session at Default stays at Default, whatever the config default", async () => { const { m } = manager(() => new MockSessionProvider("claude", []), "claude", "high"); const sid = await create(m); From 43860b5d788462694895fbc1a48caf26fcffb7ea Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 21:52:38 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20#417=20round=206=20=E2=80=94=20an?= =?UTF-8?q?=20effort=20change=20that=20needs=20a=20rebuild=20waits=20for?= =?UTF-8?q?=20background=20work;=20Codex's=20catalog=20shows=20Default=20a?= =?UTF-8?q?s=20it=20really=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A change the backend can't take live (and so rebuilds it) waits while the session has background work running, so that work isn't stopped; it lands on the first turn with none. The same level set again while a change is still on its way says so. - Codex catalog: a configured model it doesn't list leaves no row marked default (not even its own); a configured effort is every row's default, as Default sends it. - pi's agent dir may be a file:// URL, as pi accepts. - CLI help names --effort default; docs: Claude reads project settings too; a rebuild waits for background work. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 3 ++- src/cli.ts | 2 +- src/daemon/account-apply.ts | 2 ++ src/daemon/providers/codex/index.ts | 12 +++++++-- src/daemon/session.ts | 37 ++++++++++++++++++++++----- src/tests/fixtures/fake-codex.ts | 1 + src/tests/provider-claude.test.ts | 10 ++++++-- src/tests/provider-codex.test.ts | 39 +++++++++++++++++++++++++++++ src/tests/thinking-effort.test.ts | 16 ++++++++++++ 9 files changed, 110 insertions(+), 12 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index c36c10b9..28bfe408 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -40,6 +40,7 @@ A level the server refuses fails the turn with a note saying so; pick another, o A change never cuts a running turn short. - **Now**, on the running backend: Claude (except to or from Off, or back to Default), Qwen (except back to Default), pi. + A change that needs a rebuild waits while the backend has background work running, so that work isn't stopped. - **From the next turn**: Codex, OpenAI, llama.cpp, Gemini — they take it with every request — and the exceptions above, whose next turn starts a fresh query that resumes the conversation. - **After a restart, between turns**: the Gemini CLI, which reads it only from its settings at startup. It restarts before the next turn that starts with no background work running, and resumes the conversation, as after a model change. @@ -70,5 +71,5 @@ Notes: The overlays live under `~/.codeoid/accounts/pi-session/`. pi's OAuth refresh locks the real `auth.json`, so codeoid's pi and one you run yourself refresh in turn; only a sign-in or sign-out made in both at the same moment isn't serialized. - The Gemini CLI and pi overlays of an account's home are removed with the account. -- **At Default**, a backend reads its own config as it always did: Qwen applies a project's `.qwen/settings.json` reasoning effort, Claude your Claude Code effort setting. A level you set overrides them. +- **At Default**, a backend reads its own config as it always did: Qwen applies a project's `.qwen/settings.json` reasoning effort, Claude your Claude Code effort setting (your own, or the project's `.claude/settings.json`). A level you set overrides them. - **A project's own Gemini CLI settings** (`.gemini/settings.json` in the workdir) outrank your user settings, so a project that redefines `chat-base-2.5` or `chat-base-3` there overrides the effort on the Gemini CLI. diff --git a/src/cli.ts b/src/cli.ts index 07b59345..250ff53f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -990,7 +990,7 @@ program ) .option( "--effort ", - "Thinking effort: off | minimal | low | medium | high | xhigh | max (default: session.defaultEffort, else the backend's own). Change it later with /effort.", + "Thinking effort: off | minimal | low | medium | high | xhigh | max, or default for the backend's own (omitted: session.defaultEffort, else the backend's own). Change it later with /effort.", ) .option( "--pack ", diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index f8eb09d4..507aa2c3 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -15,6 +15,7 @@ import { spawn } from "node:child_process"; import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { atomicWrite, getConfigDir } from "../config.js"; import type { EffortLevel } from "../protocol/types.js"; import { AccountError, overlayKey, type ResolvedAccount } from "./accounts.js"; @@ -358,6 +359,7 @@ export function defaultPiAgentDir(env: Record = proc /** pi's agent dir, as pi reads PI_CODING_AGENT_DIR (a leading ~ is home). */ export function piAgentDirOf(env: Record): string { const dir = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); + if (dir.startsWith("file://")) return fileURLToPath(dir); // pi takes a file URL too return dir === "~" || dir.startsWith("~/") ? join(env.HOME || homedir(), dir.slice(1)) : dir; } diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index b43116b9..18b64276 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -883,14 +883,22 @@ export class CodexProvider implements SessionProvider { const listed = await this.listModels(); // The model codex runs when none is chosen (#417): the user's configured // one, else its catalog's default — so a picker shows that model's levels. + // A configured effort is what Default sends for every model: shown as theirs. let configured: string | null = null; + let configuredEffort: EffortLevel | null = null; try { - const read = (await this.#proc!.request("config/read", {})) as { config?: { model?: string | null } }; + const read = (await this.#proc!.request("config/read", {})) as { config?: { model?: string | null; model_reasoning_effort?: string | null } }; configured = read.config?.model ?? null; + configuredEffort = fromCodexEffort(read.config?.model_reasoning_effort ?? undefined); } catch { // Unknown: the catalog's own default stands. } - const models = configured && listed.some((m) => m.id === configured) ? listed.map((m) => ({ ...m, isDefault: m.id === configured })) : listed; + // A configured model the catalog doesn't list: no row is the one codex runs. + const models = listed.map((m) => ({ + ...m, + isDefault: configured ? m.id === configured : m.isDefault === true, + ...(configuredEffort ? { defaultEffort: configuredEffort } : {}), + })); if (models.length > 0) { this.#modelsReported = true; this.#onModels( diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 61f36114..b8818bb8 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -918,6 +918,8 @@ export class Session { #effort: EffortLevel | null = null; /** A backend whose effort is a startup setting (the Gemini CLI) is rebuilt between turns, once no work is running. */ #effortRestartPending = false; + /** The effort the backend was last handed (#417); undefined before the first turn. */ + #effortSent: EffortLevel | null | undefined = undefined; // ── Auto-rotation (Layer D) ──────────────────────────────────────────── // Claude Code's backing session id — distinct from codeoid's public @@ -3241,7 +3243,7 @@ export class Session { history: this.#accumulator.history, userMessage: content, model: this.#model ?? undefined, - effort: this.#effort ?? undefined, + effort: this.#turnEffort() ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#buildPromptAppend(), @@ -3332,7 +3334,7 @@ export class Session { history: this.#accumulator.history, userMessage: turnPrompt, model: this.#model ?? undefined, - effort: this.#effort ?? undefined, + effort: this.#turnEffort() ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#composePromptAppend(hookPromptAppend), @@ -4316,7 +4318,7 @@ export class Session { history: this.#accumulator.history, userMessage: prompt, model: this.#model ?? undefined, - effort: this.#effort ?? undefined, + effort: this.#turnEffort() ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#composePromptAppend(), @@ -5081,19 +5083,30 @@ export class Session { ); let applies: EffortChangeResult["applies"] = "next-turn"; if (effort === prev) { - applies = this.#effortRestartPending ? "restart" : "now"; + // Nothing new — but a change still on its way says so. + applies = this.#effortRestartPending ? "restart" : this.#effortSent !== undefined && this.#effortSent !== effort ? "next-turn" : "now"; } else if (this.#provider.effortChange === "restart") { this.#effortRestartPending = true; applies = "restart"; } else if (this.#provider.applyEffort) { try { - if (await this.#provider.applyEffort(effort)) applies = "now"; + if (await this.#provider.applyEffort(effort)) { + applies = "now"; + this.#effortSent = effort; // the running backend has it + } } catch (err) { console.error(`[codeoid/session ${this.id}] applying thinking effort live failed (it applies from the next turn): ${err instanceof Error ? err.message : String(err)}`); } } const shown = effort ?? "the backend's default"; - const when = applies === "now" ? "" : applies === "restart" ? " — the backend restarts with it between turns, once no work is running" : " — from the next turn"; + const when = + applies === "now" + ? "" + : applies === "restart" + ? " — the backend restarts with it between turns, once no work is running" + : this.#backgroundTasks.size > 0 + ? " — from the next turn with no background work running" + : " — from the next turn"; const info = this.#makeMessage("info", `⎆ Thinking effort: ${shown}${when}.`, SYSTEM_IDENTITY, undefined, undefined, { event: "session.effort_changed", effort, @@ -5106,6 +5119,18 @@ export class Session { return { effort, applies }; } + /** + * The effort a turn hands the backend (#417). A change the backend can't + * take live rebuilds it, which would stop its background work: while any + * runs, a turn keeps the effort the backend already has, and the change + * lands on the first turn with none. + */ + #turnEffort(): EffortLevel | null { + const effort = this.#backgroundTasks.size > 0 && this.#effortSent !== undefined ? this.#effortSent : this.#effort; + this.#effortSent = effort; + return effort; + } + /** Rebuild a backend that takes its effort only at startup, between turns (#417). */ async #applyEffortRestart(): Promise { if (!this.#effortRestartPending || this.#mcpBusy()) return; diff --git a/src/tests/fixtures/fake-codex.ts b/src/tests/fixtures/fake-codex.ts index a1753a14..36b1ae6a 100644 --- a/src/tests/fixtures/fake-codex.ts +++ b/src/tests/fixtures/fake-codex.ts @@ -443,6 +443,7 @@ async function handle(frame: Record): Promise { description: "Balanced agentic coding model.", supportedReasoningEfforts: [{ reasoningEffort: "none" }, { reasoningEffort: "low" }, { reasoningEffort: "high" }, { reasoningEffort: "ultra" }], defaultReasoningEffort: "low", + ...(process.env.CODEX_FAKE_LIST_DEFAULT ? { isDefault: true } : {}), }, ], }, diff --git a/src/tests/provider-claude.test.ts b/src/tests/provider-claude.test.ts index cc710fb0..def6d7f5 100644 --- a/src/tests/provider-claude.test.ts +++ b/src/tests/provider-claude.test.ts @@ -2679,10 +2679,16 @@ describe("thinking effort", () => { void turn(provider, "max"); for (let i = 0; i < 20; i++) await Promise.resolve(); expect(queryCallCount).toBe(1); - // ...Off does: a fresh query, with thinking disabled. - const off = turn(provider, "off"); + // ...nor does a turn at "max"; Default does — a fresh query with no effort option. + const back = turn(provider); for (let i = 0; i < 50 && queryCallCount < 2; i++) await Bun.sleep(2); expect(queryCallCount).toBe(2); + expect(options().effort).toBeUndefined(); + void back; + // ...Off does: a fresh query, with thinking disabled. + const off = turn(provider, "off"); + for (let i = 0; i < 50 && queryCallCount < 3; i++) await Bun.sleep(2); + expect(queryCallCount).toBe(3); expect(options().thinking).toEqual({ type: "disabled" }); release(); for await (const _ of off.events) { diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts index b88579df..f09d79fa 100644 --- a/src/tests/provider-codex.test.ts +++ b/src/tests/provider-codex.test.ts @@ -1359,3 +1359,42 @@ describe("CodexProvider – the model it runs unasked (#417)", () => { expect(reported).toEqual([{ value: "gpt-5.6-terra", isDefault: true }]); }); }); + +describe("CodexProvider – Default as the catalog shows it (#417)", () => { + const report = async (env: Record) => { + const saved: Record = {}; + for (const [k, v] of Object.entries(env)) { + saved[k] = process.env[k]; + process.env[k] = v; + } + const reported: Array<{ value: string; isDefault?: boolean; defaultEffort?: string }> = []; + try { + const p = new CodexProvider({ + sessionId: "sess-e", + initialBackingId: "sess-e", + command: process.execPath, + argsPrefix: [FIXTURE], + store: {} as Store, + onModels: (models) => + reported.push(...models.map((m) => ({ value: m.value, ...(m.isDefault ? { isDefault: true } : {}), ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}) }))), + }); + await collect(p.runTurn(turnOpts("hello"))); + await p.teardown(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + return reported; + }; + + it("a configured model the catalog doesn't list: no row is the default, not even the catalog's own", async () => { + expect(await report({ CODEX_FAKE_LIST_DEFAULT: "1" })).toEqual([{ value: "gpt-5.6-terra", isDefault: true, defaultEffort: "low" }]); + expect(await report({ CODEX_FAKE_LIST_DEFAULT: "1", CODEX_FAKE_CONFIG_MODEL: "my-unlisted-model" })).toEqual([{ value: "gpt-5.6-terra", defaultEffort: "low" }]); + }); + + it("a configured effort is every row's default — what Default sends", async () => { + expect(await report({ CODEX_FAKE_CONFIG_EFFORT: "xhigh" })).toEqual([{ value: "gpt-5.6-terra", defaultEffort: "xhigh" }]); + }); +}); diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index d970a716..40faba1f 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -138,6 +138,7 @@ describe("pi's settings overlay", () => { mkdirSync(join(tmp, "h", "x"), { recursive: true }); writeFileSync(join(tmp, "h", "x", "settings.json"), JSON.stringify({ defaultThinkingLevel: "minimal" })); expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: "~/x", HOME: join(tmp, "h") })).toBe("minimal"); + expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: `file://${join(tmp, "h", "x")}` })).toBe("minimal"); // and a file URL expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: join(tmp, "none") })).toBe("medium"); }); @@ -272,6 +273,21 @@ describe("a session's thinking effort", () => { expect((providers[0] as LiveProvider).applied).toEqual(["max"]); }); + it("a change that needs a rebuild waits while background work runs; the same level again says it's still coming", async () => { + const { m, providers } = manager(() => new MockSessionProvider("claude", [])); + const sid = await create(m, "high"); + await turn(m, sid); + providers[0]!.onSessionEvent!({ type: "background_tasks", tasks: [{ id: "bg1", kind: "subagent", description: "x", status: "running" }] }); + expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "next-turn" } }); + expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "next-turn" } }); // still on its way + await turn(m, sid); + expect(providers[0]!.capturedOpts.at(-1)?.effort).toBe("high"); // background work isn't stopped for it + providers[0]!.onSessionEvent!({ type: "background_tasks", tasks: [] }); + await turn(m, sid); + expect(providers[0]!.capturedOpts.at(-1)?.effort).toBe("low"); + expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "now" } }); // in effect + }); + it("rebuilds a backend that takes it only at startup — before the next turn, never under a running one", async () => { const { m, providers } = manager(() => new RestartProvider("gemini-cli", []), "gemini-cli"); const sid = await create(m); From 169694e245c9e4fa10274ad84a2eee863756726b Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 22:05:29 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20#417=20round=207=20=E2=80=94=20wha?= =?UTF-8?q?t=20Default=20means=20is=20the=20session's=20own;=20a=20held=20?= =?UTF-8?q?effort=20change=20is=20shown=20as=20pending=20and=20announced?= =?UTF-8?q?=20when=20it=20lands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex: the session reports what Default means for it (the configured effort, project config included, on the model the thread runs) and that model; the pickers prefer these to the daemon-wide catalog, which stays the user's own config (no project can change what other sessions see). A configured effort shows as a model's default only where the model takes it. pi reports the user's default the same way. - A change held back by background work: SessionInfo.effortPending, shown in the web, TUI and Telegram pickers; the message points at Stop; an info message when it lands. - piAgentDirOf never throws on a malformed file:// URL (the secret-path guard calls it on every tool call). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 2 ++ packages/protocol/src/types.ts | 14 +++++++++++++ src/daemon/account-apply.ts | 8 +++++++- src/daemon/providers/codex/index.ts | 27 ++++++++++++++++++++++---- src/daemon/providers/interface.ts | 4 ++++ src/daemon/providers/pi/index.ts | 6 ++++++ src/daemon/session.ts | 22 +++++++++++++++++++-- src/frontends/telegram/index.ts | 6 ++++-- src/tests/fixtures/fake-codex.ts | 6 +++++- src/tests/provider-codex.test.ts | 23 ++++++++++++++++++++-- src/tests/thinking-effort.test.ts | 5 ++++- src/tui/components/BackendPicker.tsx | 9 +++++++-- src/tui/components/Modal.tsx | 4 +++- web/src/components/SessionControls.tsx | 20 +++++++++++++++---- 14 files changed, 136 insertions(+), 20 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index 28bfe408..cddf9e92 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -33,6 +33,7 @@ Where the backend says, the pickers offer only the model's own levels: - pi reports which models reason and which levels each takes. The others (OpenAI and OpenAI-compatible endpoints, llama.cpp, Qwen, Gemini, the Gemini CLI) don't, so every level is offered and marked "support unknown". +The Default entry shows what Default means on that session once the backend has said (Codex: your configured effort, the project's included, on the model it actually runs; pi: your default). A level the server refuses fails the turn with a note saying so; pick another, or Default. ## When a change applies @@ -41,6 +42,7 @@ A change never cuts a running turn short. - **Now**, on the running backend: Claude (except to or from Off, or back to Default), Qwen (except back to Default), pi. A change that needs a rebuild waits while the backend has background work running, so that work isn't stopped. + Until a change is in effect the pickers mark it "pending" (Stop ends background work), and the session says when it lands. - **From the next turn**: Codex, OpenAI, llama.cpp, Gemini — they take it with every request — and the exceptions above, whose next turn starts a fresh query that resumes the conversation. - **After a restart, between turns**: the Gemini CLI, which reads it only from its settings at startup. It restarts before the next turn that starts with no background work running, and resumes the conversation, as after a model change. diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 14baaffb..d4d28efa 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -487,6 +487,20 @@ export interface SessionInfo { fallbackModel?: string; /** Thinking effort set on this session (#417); absent = the backend's default. */ effort?: EffortLevel; + /** + * What Default means on this session right now, when the backend says + * (Codex: the configured effort, the project's included, else the running + * model's; pi: the user's default). Known once the backend has started. + */ + defaultEffort?: EffortLevel; + /** The model the backend runs when the session names none, once known (Codex). */ + runningModel?: string; + /** + * The effort set hasn't reached the backend yet (#417): it lands with the + * next turn, or — when it needs a rebuild — the first one with no + * background work running (Stop ends that work). + */ + effortPending?: boolean; /** * Lineage for a session created via `session.fork`. Absent = not a fork. * Frontends surface it as a chip ("⑃ forked from · turn ") diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index 507aa2c3..d7b35fd1 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -359,7 +359,13 @@ export function defaultPiAgentDir(env: Record = proc /** pi's agent dir, as pi reads PI_CODING_AGENT_DIR (a leading ~ is home). */ export function piAgentDirOf(env: Record): string { const dir = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); - if (dir.startsWith("file://")) return fileURLToPath(dir); // pi takes a file URL too + if (dir.startsWith("file://")) { + try { + return fileURLToPath(dir); // pi takes a file URL too + } catch { + return dir; // not one pi could open either: never a reason to throw from the secret-path guard + } + } return dir === "~" || dir.startsWith("~/") ? join(env.HOME || homedir(), dir.slice(1)) : dir; } diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index 18b64276..6c5952df 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -298,6 +298,8 @@ export class CodexProvider implements SessionProvider { readonly #modelDefaultEffort = new Map(); #threadModel: string | null = null; #configEffort: string | null | undefined = undefined; + /** The baseline last worked out, for the session's pickers. */ + #defaultEffort: string | null = null; #modelListTried = false; #hasQueried = false; #modelsReported = false; @@ -712,8 +714,25 @@ export class CodexProvider implements SessionProvider { // ── Internal ────────────────────────────────────────────────────────── /** turn/start's effort (#417): the session's, else what codex would use unasked — or nothing when that can't be known. */ + /** What Default means here now (#417): the effort codex would use unasked, once worked out. */ + get defaultEffort(): EffortLevel | null { + return fromCodexEffort(this.#defaultEffort ?? undefined); + } + + /** The model the thread runs (codex's configured one, project config included), once known. */ + get runningModel(): string | null { + return this.#threadModel; + } + async #effortParam(opts: TurnOpts): Promise<{ effort?: string }> { + const baseline = await this.#baselineEffort(opts); + this.#defaultEffort = baseline; if (opts.effort) return { effort: toCodexEffort(opts.effort) }; + return baseline ? { effort: baseline } : {}; + } + + /** The effort codex itself uses unasked: the configured one (project layers included), else the running model's own. */ + async #baselineEffort(opts: TurnOpts): Promise { if (this.#configEffort === undefined) { try { const read = (await this.#proc!.request("config/read", { cwd: opts.workdir })) as { config?: { model_reasoning_effort?: string | null } }; @@ -722,14 +741,13 @@ export class CodexProvider implements SessionProvider { // Unknown this time; asked again next turn. } } - if (this.#configEffort) return { effort: this.#configEffort }; + if (this.#configEffort) return this.#configEffort; const model = opts.model ?? this.#threadModel; if (model && !this.#modelDefaultEffort.has(model) && !this.#modelListTried) { this.#modelListTried = true; // once: a model the list doesn't carry stays unknown await this.listModels(); } - const fallback = model ? this.#modelDefaultEffort.get(model) : undefined; - return fallback ? { effort: fallback } : {}; + return (model && this.#modelDefaultEffort.get(model)) || null; } async #startTurn(opts: TurnOpts): Promise { @@ -897,7 +915,8 @@ export class CodexProvider implements SessionProvider { const models = listed.map((m) => ({ ...m, isDefault: configured ? m.id === configured : m.isDefault === true, - ...(configuredEffort ? { defaultEffort: configuredEffort } : {}), + // ...for a model that takes it (one that doesn't: codex adjusts; its own default is the best guess). + ...(configuredEffort && (!m.efforts || m.efforts.includes(configuredEffort)) ? { defaultEffort: configuredEffort } : {}), })); if (models.length > 0) { this.#modelsReported = true; diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index ccfc8c96..36ce95e1 100644 --- a/src/daemon/providers/interface.ts +++ b/src/daemon/providers/interface.ts @@ -618,6 +618,10 @@ export interface AgentProvider { * before the next turn. */ readonly effortChange?: "turn" | "restart"; + /** What Default means on this backend now, once known (#417) — for the session's pickers. */ + readonly defaultEffort?: EffortLevel | null; + /** The model it runs when the session names none, once known. */ + readonly runningModel?: string | null; /** * Apply a new effort to the running backend now, where it can (Claude, * Qwen, pi) — never cutting a turn short. True when it did; false (or diff --git a/src/daemon/providers/pi/index.ts b/src/daemon/providers/pi/index.ts index 5c6cb7b6..ac3b37d3 100644 --- a/src/daemon/providers/pi/index.ts +++ b/src/daemon/providers/pi/index.ts @@ -421,6 +421,12 @@ export class PiProvider implements SessionProvider { } /** A new thinking effort on the running pi, live (#417): it takes the next request at that level. */ + /** What Default means here (#417): the user's own pi default, once read. */ + get defaultEffort(): EffortLevel | null { + const l = this.#defaultThinkingLevel; + return l && (EFFORT_LEVELS as readonly string[]).includes(l) ? (l as EffortLevel) : null; + } + async applyEffort(effort: EffortLevel | null): Promise { const level = effort ?? this.#defaultThinkingLevel; if (!this.#proc?.alive || !level) return false; diff --git a/src/daemon/session.ts b/src/daemon/session.ts index b8818bb8..7aa2b6dd 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -920,6 +920,8 @@ export class Session { #effortRestartPending = false; /** The effort the backend was last handed (#417); undefined before the first turn. */ #effortSent: EffortLevel | null | undefined = undefined; + /** The last turn kept the old effort for background work: its change is announced when it lands. */ + #effortHeld = false; // ── Auto-rotation (Layer D) ──────────────────────────────────────────── // Claude Code's backing session id — distinct from codeoid's public @@ -4688,6 +4690,9 @@ export class Session { model: this.#model ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, effort: this.#effort ?? undefined, + ...(this.#provider.defaultEffort ? { defaultEffort: this.#provider.defaultEffort } : {}), + ...(this.#effortRestartPending || (this.#effortSent !== undefined && this.#effortSent !== this.#effort) ? { effortPending: true } : {}), + ...(this.#provider.runningModel ? { runningModel: this.#provider.runningModel } : {}), forkedFrom: this.forkedFrom, worktree: this.worktree, collaboration: this.collaboration, @@ -5105,7 +5110,7 @@ export class Session { : applies === "restart" ? " — the backend restarts with it between turns, once no work is running" : this.#backgroundTasks.size > 0 - ? " — from the next turn with no background work running" + ? " — from the next turn with no background work running (Stop ends it)" : " — from the next turn"; const info = this.#makeMessage("info", `⎆ Thinking effort: ${shown}${when}.`, SYSTEM_IDENTITY, undefined, undefined, { event: "session.effort_changed", @@ -5126,8 +5131,21 @@ export class Session { * lands on the first turn with none. */ #turnEffort(): EffortLevel | null { - const effort = this.#backgroundTasks.size > 0 && this.#effortSent !== undefined ? this.#effortSent : this.#effort; + const held = this.#backgroundTasks.size > 0 && this.#effortSent !== undefined; + const effort = held ? (this.#effortSent as EffortLevel | null) : this.#effort; + // A change background work held back is in effect now: say so (an ordinary next-turn change was announced as such). + const landed = !held && this.#effortHeld && this.#effortSent !== effort; + this.#effortHeld = held && effort !== this.#effort; this.#effortSent = effort; + if (landed) { + const info = this.#makeMessage("info", `⎆ Thinking effort now ${effort ?? "the backend's default"}.`, SYSTEM_IDENTITY, undefined, undefined, { + event: "session.effort_applied", + effort, + }); + this.#persistAndBuffer(info); + this.#broadcastRaw(info); + this.#broadcastInfoUpdate(); + } return effort; } diff --git a/src/frontends/telegram/index.ts b/src/frontends/telegram/index.ts index 30b3cab5..b338f174 100644 --- a/src/frontends/telegram/index.ts +++ b/src/frontends/telegram/index.ts @@ -810,8 +810,10 @@ export class TelegramFrontend implements Frontend { } const arg = ctx.message?.text?.split(/\s+/)[1]; if (!arg) { - const current = state.attachedSessionName ? this.#manager.findByName(state.attachedSessionName, state.auth!)?.toInfo().effort : undefined; - await ctx.reply(`Thinking effort: ${current ? EFFORT_LABELS[current] : "the backend's default"}.\nSet it with /effort ${EFFORT_LEVELS.join("|")}|default`); + const info = state.attachedSessionName ? this.#manager.findByName(state.attachedSessionName, state.auth!)?.toInfo() : undefined; + const current = info?.effort; + const pending = info?.effortPending ? " (pending: it lands with the next turn, or once background work ends)" : ""; + await ctx.reply(`Thinking effort: ${current ? EFFORT_LABELS[current] : "the backend's default"}${pending}.\nSet it with /effort ${EFFORT_LEVELS.join("|")}|default`); return; } let effort: EffortLevel | null; diff --git a/src/tests/fixtures/fake-codex.ts b/src/tests/fixtures/fake-codex.ts index 36b1ae6a..797013d8 100644 --- a/src/tests/fixtures/fake-codex.ts +++ b/src/tests/fixtures/fake-codex.ts @@ -425,7 +425,11 @@ async function handle(frame: Record): Promise { send({ id, result: { - config: { model_reasoning_effort: process.env.CODEX_FAKE_CONFIG_EFFORT || null, model: process.env.CODEX_FAKE_CONFIG_MODEL || null }, + // A project layer (read only with a cwd, as codex does) outranks the user's. + config: { + model_reasoning_effort: (params.cwd ? process.env.CODEX_FAKE_PROJECT_EFFORT : undefined) || process.env.CODEX_FAKE_CONFIG_EFFORT || null, + model: process.env.CODEX_FAKE_CONFIG_MODEL || null, + }, origins: {}, layers: null, }, diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts index f09d79fa..fcaf3763 100644 --- a/src/tests/provider-codex.test.ts +++ b/src/tests/provider-codex.test.ts @@ -1394,7 +1394,26 @@ describe("CodexProvider – Default as the catalog shows it (#417)", () => { expect(await report({ CODEX_FAKE_LIST_DEFAULT: "1", CODEX_FAKE_CONFIG_MODEL: "my-unlisted-model" })).toEqual([{ value: "gpt-5.6-terra", defaultEffort: "low" }]); }); - it("a configured effort is every row's default — what Default sends", async () => { - expect(await report({ CODEX_FAKE_CONFIG_EFFORT: "xhigh" })).toEqual([{ value: "gpt-5.6-terra", defaultEffort: "xhigh" }]); + it("a configured effort is the default of every model that takes it — what Default sends", async () => { + expect(await report({ CODEX_FAKE_CONFIG_EFFORT: "high" })).toEqual([{ value: "gpt-5.6-terra", defaultEffort: "high" }]); + // One the model doesn't take: its own default stands. + expect(await report({ CODEX_FAKE_CONFIG_EFFORT: "xhigh" })).toEqual([{ value: "gpt-5.6-terra", defaultEffort: "low" }]); + }); + + it("the session itself knows what Default means and which model runs — the project's config included", async () => { + const saved = { u: process.env.CODEX_FAKE_CONFIG_EFFORT, p: process.env.CODEX_FAKE_PROJECT_EFFORT }; + process.env.CODEX_FAKE_CONFIG_EFFORT = "low"; + process.env.CODEX_FAKE_PROJECT_EFFORT = "high"; + try { + const p = makeProvider(); + await collect(p.runTurn(turnOpts("hello"))); + expect([p.defaultEffort, p.runningModel]).toEqual(["high", "gpt-5.6-terra"]); + await p.teardown(); + } finally { + for (const [k, v] of [["CODEX_FAKE_CONFIG_EFFORT", saved.u], ["CODEX_FAKE_PROJECT_EFFORT", saved.p]] as const) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } }); }); diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index 40faba1f..2e767b64 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseClientMessage } from "@highflame/codeoid-protocol/schemas"; import type { CodeoidConfig } from "../config.js"; -import { applyGeminiCliEffort, applyPiSettingsOverlay, geminiEffortSettings, piDefaultThinkingLevel } from "../daemon/account-apply.js"; +import { applyGeminiCliEffort, applyPiSettingsOverlay, geminiEffortSettings, piAgentDirOf, piDefaultThinkingLevel } from "../daemon/account-apply.js"; import { AccountStore } from "../daemon/accounts.js"; import { geminiThinkingConfig } from "../daemon/providers/gemini/index.js"; import { llamaCppEffortParams } from "../daemon/providers/llamacpp/index.js"; @@ -139,6 +139,7 @@ describe("pi's settings overlay", () => { writeFileSync(join(tmp, "h", "x", "settings.json"), JSON.stringify({ defaultThinkingLevel: "minimal" })); expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: "~/x", HOME: join(tmp, "h") })).toBe("minimal"); expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: `file://${join(tmp, "h", "x")}` })).toBe("minimal"); // and a file URL + expect(piAgentDirOf({ PI_CODING_AGENT_DIR: "file://somehost/x" })).toBe("file://somehost/x"); // a bad one never throws (the secret-path guard reads it) expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: join(tmp, "none") })).toBe("medium"); }); @@ -282,9 +283,11 @@ describe("a session's thinking effort", () => { expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "next-turn" } }); // still on its way await turn(m, sid); expect(providers[0]!.capturedOpts.at(-1)?.effort).toBe("high"); // background work isn't stopped for it + expect(m._sessionForTest(sid)!.toInfo().effortPending).toBe(true); // and the session says it's held providers[0]!.onSessionEvent!({ type: "background_tasks", tasks: [] }); await turn(m, sid); expect(providers[0]!.capturedOpts.at(-1)?.effort).toBe("low"); + expect(m._sessionForTest(sid)!.toInfo().effortPending).toBeUndefined(); expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "now" } }); // in effect }); diff --git a/src/tui/components/BackendPicker.tsx b/src/tui/components/BackendPicker.tsx index 24567843..752719ce 100644 --- a/src/tui/components/BackendPicker.tsx +++ b/src/tui/components/BackendPicker.tsx @@ -16,6 +16,10 @@ interface Props { provider: string; /** For the effort picker: the session's model, whose levels are offered. */ model?: string; + /** For the effort picker: what Default means on the session, when the backend says. */ + sessionDefaultEffort?: EffortLevel; + /** For the effort picker: the set level hasn't reached the backend yet. */ + effortPending?: boolean; /** Name of the session this picker changes, shown in the header. */ sessionName?: string; current: string; @@ -25,7 +29,7 @@ interface Props { onCancel: () => void; } -export function BackendPicker({ kind, provider, model, sessionName, current, providers, client, onPick, onCancel }: Props) { +export function BackendPicker({ kind, provider, model, sessionDefaultEffort, effortPending, sessionName, current, providers, client, onPick, onCancel }: Props) { const [choices, setChoices] = useState([]); const [loading, setLoading] = useState(kind !== "provider"); const [busy, setBusy] = useState(false); @@ -51,7 +55,7 @@ export function BackendPicker({ kind, provider, model, sessionName, current, pro const result = await client.listModels(provider); if (cancelled) return; const entry = result.models.find((m) => m.value === model) ?? (model ? undefined : result.models.find((m) => m.isDefault)); - setChoices(effortChoices(entry?.efforts, entry?.defaultEffort)); + setChoices(effortChoices(entry?.efforts, sessionDefaultEffort ?? entry?.defaultEffort)); setLive(true); } else if (kind === "model") { const result = await client.listModels(provider); @@ -111,6 +115,7 @@ export function BackendPicker({ kind, provider, model, sessionName, current, pro return ( Select {kind === "effort" ? "thinking effort" : kind}{kind !== "provider" ? ` (${provider})` : ""}{sessionName ? ` for ${sessionName}` : ""} + {kind === "effort" && effortPending && The current choice is pending: it lands with the next turn, or once background work ends (Stop ends it).} {loading ? Loading… : options.slice(start, start + 8).map((choice, offset) => ( // A server may list an id twice: the position keeps keys unique. diff --git a/src/tui/components/Modal.tsx b/src/tui/components/Modal.tsx index d07813fe..9ada71e5 100644 --- a/src/tui/components/Modal.tsx +++ b/src/tui/components/Modal.tsx @@ -105,7 +105,9 @@ export function Modal(props: Props) { key={kind === "model" || kind === "effort" ? `${kind}:${provider}` : kind} kind={kind} provider={provider} - model={target.info.model} + model={target.info.model ?? target.info.runningModel} + sessionDefaultEffort={target.info.defaultEffort} + effortPending={target.info.effortPending === true} sessionName={target.info.name} providers={props.providers} client={props.client} diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 7d0b8812..f3decd82 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -119,7 +119,13 @@ const SessionControls: Component = () => { current={s().model} provider={s().providerId} /> - + = (props) => { +const EffortPicker: Component<{ sessionId: string; current?: EffortLevel; pending?: boolean; model?: string; sessionDefault?: EffortLevel }> = (props) => { const open = effortPickerOpen; const setOpen = setEffortPickerOpen; const act = createAction(); @@ -863,7 +869,13 @@ const EffortPicker: Component<{ sessionId: string; current?: EffortLevel; model? class="flex items-center gap-1 rounded border border-border bg-bg px-2 py-1 font-mono uppercase tracking-wider text-fg-muted hover:border-accent/40 hover:text-fg" title="How hard the model thinks before it answers" > - effort {props.current ? EFFORT_LABELS[props.current] : "default"} ▾ + effort {props.current ? EFFORT_LABELS[props.current] : "default"} + + + (pending) + + {" "} + ▾
Default - + {(d) => · {EFFORT_LABELS[d()]}} From 38d27c5408f080be4570fd37eef3433788cd18e2 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 22:17:14 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20#417=20round=208=20=E2=80=94=20Cod?= =?UTF-8?q?ex's=20Default=20is=20a=20level=20its=20model=20takes,=20everyw?= =?UTF-8?q?here=20it's=20shown=20or=20sent;=20pi's=20agent=20dir=20must=20?= =?UTF-8?q?be=20absolute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex: a configured effort counts as Default only for a model that takes it (else the model's own) — the same rule for the catalog, the session's report, and what turn/start sends. - The session's Default is shown only while it's for the model in use (stale after a model change until the next turn); pickers drop a Default the model's known levels don't include; the TUI picker follows the session while open; runningModel is bounded. - /effort's reply says when a change waits for background work (EffortChangeResult.waitsForBackground). - pi: an agent dir that isn't absolute (or an unparseable file:// URL) is refused rather than building pi's logins under the daemon's own dir. - Doc comments back on their own members. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/slash.test.ts | 1 + packages/core/src/slash.ts | 1 + packages/protocol/src/types.ts | 4 +++- relative/pi/auth.json | 1 + src/daemon/account-apply.ts | 8 ++++++-- src/daemon/providers/codex/index.ts | 22 +++++++++++++++++---- src/daemon/providers/pi/index.ts | 2 +- src/daemon/session.ts | 11 ++++++++--- src/tests/provider-codex.test.ts | 25 ++++++++++++++++++++---- src/tests/thinking-effort.test.ts | 27 ++++++++++++++++++++++++++ src/tui/components/BackendPicker.tsx | 6 ++++-- web/src/components/SessionControls.tsx | 7 ++++++- 12 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 relative/pi/auth.json diff --git a/packages/core/src/slash.test.ts b/packages/core/src/slash.test.ts index 94c83e9c..08bfc283 100644 --- a/packages/core/src/slash.test.ts +++ b/packages/core/src/slash.test.ts @@ -85,6 +85,7 @@ describe("parseSlash", () => { expect(() => parseSlash("/effort turbo")).toThrow(/off\|minimal/); expect(effortChangeSummary({ effort: "max", applies: "restart" })).toMatch(/restarts/); expect(effortChangeSummary({ effort: null, applies: "next-turn" })).toBe("Thinking effort: the backend's default, from the next turn."); + expect(effortChangeSummary({ effort: "off", applies: "next-turn", waitsForBackground: true })).toMatch(/no background work running \(Stop ends it\)/); }); it("bare /model opens the picker instead of erroring", () => { diff --git a/packages/core/src/slash.ts b/packages/core/src/slash.ts index f6d513ab..6286de5e 100644 --- a/packages/core/src/slash.ts +++ b/packages/core/src/slash.ts @@ -457,5 +457,6 @@ export function effortChangeSummary(r: EffortChangeResult): string { const what = r.effort ? EFFORT_LABELS[r.effort] : "the backend's default"; if (r.applies === "now") return `Thinking effort: ${what}.`; if (r.applies === "restart") return `Thinking effort: ${what} — the backend restarts with it between turns, once no work is running.`; + if (r.waitsForBackground) return `Thinking effort: ${what}, from the next turn with no background work running (Stop ends it).`; return `Thinking effort: ${what}, from the next turn.`; } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index d4d28efa..a557242d 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -1862,8 +1862,10 @@ export interface SessionSetEffortMsg extends BaseClientMsg { /** `response.ok` data for `session.set_effort`. */ export interface EffortChangeResult { effort: EffortLevel | null; - /** When it takes effect: now, from the next turn, or once the backend restarts (before the next turn). */ + /** When it takes effect: now, from the next turn, or once the backend restarts (between turns). */ applies: "now" | "next-turn" | "restart"; + /** It needs a rebuild that waits for the session's background work to end (Stop ends it). */ + waitsForBackground?: boolean; } /** diff --git a/relative/pi/auth.json b/relative/pi/auth.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/relative/pi/auth.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index d7b35fd1..d694ec7f 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -14,7 +14,7 @@ import { spawn } from "node:child_process"; import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { atomicWrite, getConfigDir } from "../config.js"; import type { EffortLevel } from "../protocol/types.js"; @@ -407,6 +407,8 @@ export function applyPiSettingsOverlay( homesRoot: string = join(getConfigDir(), "accounts"), ): Record { const base = piAgentDirOf(env); + // A relative (or unparseable) dir would put pi's logins under the daemon's own working directory. + if (!isAbsolute(base)) throw new AccountError(`PI_CODING_AGENT_DIR must be an absolute path (it's ${JSON.stringify(base)})`); // pi creates its auth.json when it's missing — then it must be the user's, // not a file of the overlay's own that a later `pi /login` never reaches. mkdirSync(base, { recursive: true, mode: 0o700 }); @@ -460,7 +462,9 @@ export function piDefaultThinkingLevel(env: Record): */ export function applyPiAccount(account: ResolvedAccount | null, env: Record): Record { if (!account) return env; - ensureOverlayHome({ source: piAgentDirOf(process.env), target: account.home, own: ["auth.json"], shared: ["sessions"] }); + const source = piAgentDirOf(process.env); + if (!isAbsolute(source)) throw new AccountError(`PI_CODING_AGENT_DIR must be an absolute path (it's ${JSON.stringify(source)})`); + ensureOverlayHome({ source, target: account.home, own: ["auth.json"], shared: ["sessions"] }); const out = without( env, (k) => k.endsWith("_API_KEY") || k.endsWith("_API_TOKEN") || k === "PI_CODING_AGENT_DIR" || /^(ANTHROPIC|OPENAI|GOOGLE|GEMINI)_/.test(k), diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index 6c5952df..576040ac 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -296,6 +296,8 @@ export class CodexProvider implements SessionProvider { /** Each model's default effort from model/list, the model the thread runs, and the * user's own configured effort (config/read) — what Default sends (#417). */ readonly #modelDefaultEffort = new Map(); + /** Each model's efforts on codeoid's scale, from model/list. */ + readonly #modelEfforts = new Map(); #threadModel: string | null = null; #configEffort: string | null | undefined = undefined; /** The baseline last worked out, for the session's pickers. */ @@ -670,6 +672,12 @@ export class CodexProvider implements SessionProvider { for (const m of result.data ?? []) { const id = m.model ?? m.id; if (typeof id === "string" && typeof m.defaultReasoningEffort === "string") this.#modelDefaultEffort.set(id, m.defaultReasoningEffort); + if (typeof id === "string" && Array.isArray(m.supportedReasoningEfforts)) { + this.#modelEfforts.set( + id, + m.supportedReasoningEfforts.map((e) => fromCodexEffort(e?.reasoningEffort)).filter((e): e is EffortLevel => e !== null), + ); + } } return (result.data ?? []) .filter((m) => typeof (m.model ?? m.id) === "string") @@ -713,7 +721,6 @@ export class CodexProvider implements SessionProvider { // ── Internal ────────────────────────────────────────────────────────── - /** turn/start's effort (#417): the session's, else what codex would use unasked — or nothing when that can't be known. */ /** What Default means here now (#417): the effort codex would use unasked, once worked out. */ get defaultEffort(): EffortLevel | null { return fromCodexEffort(this.#defaultEffort ?? undefined); @@ -724,6 +731,7 @@ export class CodexProvider implements SessionProvider { return this.#threadModel; } + /** turn/start's effort (#417): the session's, else what codex would use unasked — or nothing when that can't be known. */ async #effortParam(opts: TurnOpts): Promise<{ effort?: string }> { const baseline = await this.#baselineEffort(opts); this.#defaultEffort = baseline; @@ -731,7 +739,11 @@ export class CodexProvider implements SessionProvider { return baseline ? { effort: baseline } : {}; } - /** The effort codex itself uses unasked: the configured one (project layers included), else the running model's own. */ + /** + * The effort codex itself uses unasked: the configured one (project layers + * included) for a model that takes it, else the running model's own — the + * same rule the catalog shows. + */ async #baselineEffort(opts: TurnOpts): Promise { if (this.#configEffort === undefined) { try { @@ -741,12 +753,14 @@ export class CodexProvider implements SessionProvider { // Unknown this time; asked again next turn. } } - if (this.#configEffort) return this.#configEffort; const model = opts.model ?? this.#threadModel; - if (model && !this.#modelDefaultEffort.has(model) && !this.#modelListTried) { + if (model && !this.#modelDefaultEffort.has(model) && !this.#modelEfforts.has(model) && !this.#modelListTried) { this.#modelListTried = true; // once: a model the list doesn't carry stays unknown await this.listModels(); } + const takes = model ? this.#modelEfforts.get(model) : undefined; + const configured = fromCodexEffort(this.#configEffort ?? undefined); + if (this.#configEffort && (!takes || (configured !== null && takes.includes(configured)))) return this.#configEffort; return (model && this.#modelDefaultEffort.get(model)) || null; } diff --git a/src/daemon/providers/pi/index.ts b/src/daemon/providers/pi/index.ts index ac3b37d3..f3b34de6 100644 --- a/src/daemon/providers/pi/index.ts +++ b/src/daemon/providers/pi/index.ts @@ -420,13 +420,13 @@ export class PiProvider implements SessionProvider { })); } - /** A new thinking effort on the running pi, live (#417): it takes the next request at that level. */ /** What Default means here (#417): the user's own pi default, once read. */ get defaultEffort(): EffortLevel | null { const l = this.#defaultThinkingLevel; return l && (EFFORT_LEVELS as readonly string[]).includes(l) ? (l as EffortLevel) : null; } + /** A new thinking effort on the running pi, live (#417): it takes the next request at that level. */ async applyEffort(effort: EffortLevel | null): Promise { const level = effort ?? this.#defaultThinkingLevel; if (!this.#proc?.alive || !level) return false; diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 7aa2b6dd..21d039c5 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -86,6 +86,7 @@ import { } from "./checkpoints.js"; import { applyRewinds, REWIND_EVENT, type TurnIndexEntry } from "./transcript.js"; import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, TurnSummary } from "../protocol/types.js"; +import { LIMITS } from "../protocol/types.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; import type { PendingToolApproval, SessionApproveOutcome } from "../protocol/types.js"; import { execFile } from "node:child_process"; @@ -4690,9 +4691,13 @@ export class Session { model: this.#model ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, effort: this.#effort ?? undefined, - ...(this.#provider.defaultEffort ? { defaultEffort: this.#provider.defaultEffort } : {}), + // What Default means — while it's for the model in use (a model change makes it stale until the next turn). + ...(this.#provider.defaultEffort && + (!this.#provider.runningModel || this.#model == null || this.#model === this.#provider.runningModel) + ? { defaultEffort: this.#provider.defaultEffort } + : {}), ...(this.#effortRestartPending || (this.#effortSent !== undefined && this.#effortSent !== this.#effort) ? { effortPending: true } : {}), - ...(this.#provider.runningModel ? { runningModel: this.#provider.runningModel } : {}), + ...(this.#provider.runningModel ? { runningModel: this.#provider.runningModel.slice(0, LIMITS.MODEL_MAX) } : {}), forkedFrom: this.forkedFrom, worktree: this.worktree, collaboration: this.collaboration, @@ -5121,7 +5126,7 @@ export class Session { this.#persistAndBuffer(info); this.#broadcastRaw(info); this.#broadcastInfoUpdate(); - return { effort, applies }; + return { effort, applies, ...(applies === "next-turn" && this.#backgroundTasks.size > 0 ? { waitsForBackground: true } : {}) }; } /** diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts index fcaf3763..b7076c1d 100644 --- a/src/tests/provider-codex.test.ts +++ b/src/tests/provider-codex.test.ts @@ -1309,18 +1309,18 @@ describe("CodexProvider – thinking effort (#417)", () => { expect(await effortSeen("off")).toBe("none"); expect(await effortSeen()).toBe("low"); // the running model's own default (no configured effort) const saved = process.env.CODEX_FAKE_CONFIG_EFFORT; - process.env.CODEX_FAKE_CONFIG_EFFORT = "xhigh"; + process.env.CODEX_FAKE_CONFIG_EFFORT = "high"; try { - expect(await effortSeen()).toBe("xhigh"); // the user's model_reasoning_effort wins + expect(await effortSeen()).toBe("high"); // the user's model_reasoning_effort wins (a level the model takes) // Read afresh for each thread, as codex does: an edit made meanwhile counts. const p = makeProvider(); await collect(p.runTurn(turnOpts("echo-effort"))); await p.teardown(); await Bun.sleep(100); // the killed app-server's exit lands before the next turn starts - process.env.CODEX_FAKE_CONFIG_EFFORT = "medium"; + process.env.CODEX_FAKE_CONFIG_EFFORT = "none"; const events = await collect(p.runTurn(turnOpts("echo-effort"))); await p.teardown(); - expect(JSON.parse((events.find((e) => e.type === "text_done") as { content: string }).content).effort).toBe("medium"); + expect(JSON.parse((events.find((e) => e.type === "text_done") as { content: string }).content).effort).toBe("none"); } finally { if (saved === undefined) delete process.env.CODEX_FAKE_CONFIG_EFFORT; else process.env.CODEX_FAKE_CONFIG_EFFORT = saved; @@ -1417,3 +1417,20 @@ describe("CodexProvider – Default as the catalog shows it (#417)", () => { } }); }); + +describe("CodexProvider – Default for a model that doesn't take the configured effort (#417)", () => { + it("sends and reports the model's own default instead — as the catalog shows", async () => { + const saved = process.env.CODEX_FAKE_CONFIG_EFFORT; + process.env.CODEX_FAKE_CONFIG_EFFORT = "xhigh"; // the fake's model takes off/low/high + try { + const p = makeProvider(); + const events = await collect(p.runTurn(turnOpts("echo-effort"))); + expect(JSON.parse((events.find((e) => e.type === "text_done") as { content: string }).content).effort).toBe("low"); + expect(p.defaultEffort).toBe("low"); + await p.teardown(); + } finally { + if (saved === undefined) delete process.env.CODEX_FAKE_CONFIG_EFFORT; + else process.env.CODEX_FAKE_CONFIG_EFFORT = saved; + } + }); +}); diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index 2e767b64..9e2f4626 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -134,6 +134,11 @@ describe("pi's settings overlay", () => { expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: agent })).toBe("low"); }); + it("refuses a pi agent dir that isn't absolute — it would put pi's logins under the daemon's own directory", () => { + expect(() => applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: "relative/pi" }, join(tmp, "homes"))).toThrow(/absolute/); + expect(() => applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: "file://somehost/x" }, join(tmp, "homes"))).toThrow(/absolute/); + }); + it("reads a ~ path as pi does", () => { mkdirSync(join(tmp, "h", "x"), { recursive: true }); writeFileSync(join(tmp, "h", "x", "settings.json"), JSON.stringify({ defaultThinkingLevel: "minimal" })); @@ -326,6 +331,19 @@ describe("a session's thinking effort", () => { expect(m._sessionForTest((r.data?.session?.id ?? r.data?.id)!)!.toInfo().effort).toBeUndefined(); }); + it("what Default means is shown only while it's for the model in use", async () => { + class Reporting extends MockSessionProvider { + readonly defaultEffort = "high" as const; + readonly runningModel = "claude-running-model"; + } + const { m } = manager(() => new Reporting("claude", [])); + const sid = await create(m); + const s = m._sessionForTest(sid)!; + expect(s.toInfo()).toMatchObject({ defaultEffort: "high", runningModel: "claude-running-model" }); + await s.setModel("opus", undefined, auth()); + expect(s.toInfo().defaultEffort).toBeUndefined(); // stale for the new model until its first turn + }); + it("a fork thinks as hard as the session it came from", async () => { const { m } = manager(() => new MockSessionProvider("claude", [])); const sid = await create(m, "xhigh"); @@ -397,3 +415,12 @@ describe("a session's thinking effort", () => { expect(r.models.find((x) => x.value === "other")?.efforts).toBeUndefined(); }); }); + +describe("the TUI effort picker", () => { + it("doesn't show a Default the model's known levels don't include", async () => { + const { effortChoices } = await import("../tui/components/BackendPicker.js"); + expect(effortChoices(["off"], "medium")[0]!.description).toBe("the backend decides"); + expect(effortChoices(["low", "medium"], "medium")[0]!.description).toBe("the backend decides (Medium)"); + expect(effortChoices(undefined, "medium")[0]!.description).toBe("the backend decides (Medium)"); + }); +}); diff --git a/src/tui/components/BackendPicker.tsx b/src/tui/components/BackendPicker.tsx index 752719ce..656e4564 100644 --- a/src/tui/components/BackendPicker.tsx +++ b/src/tui/components/BackendPicker.tsx @@ -80,7 +80,7 @@ export function BackendPicker({ kind, provider, model, sessionDefaultEffort, eff if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [client, kind, provider, revision]); + }, [client, kind, provider, revision, model, sessionDefaultEffort]); const options = kind === "provider" ? providers.map((entry) => ({ value: entry.id, label: entry.displayName, description: entry.id })) @@ -145,8 +145,10 @@ export function BackendPicker({ kind, provider, model, sessionDefaultEffort, eff */ export function effortChoices(efforts: readonly EffortLevel[] | undefined, defaultEffort?: EffortLevel): BackendChoice[] { const unknown = efforts === undefined; + // A Default the model's known levels don't include isn't shown. + const shown = defaultEffort && (!efforts || efforts.includes(defaultEffort)) ? defaultEffort : undefined; return [ - { value: "default", label: "Default", description: defaultEffort ? `the backend decides (${EFFORT_LABELS[defaultEffort]})` : "the backend decides" }, + { value: "default", label: "Default", description: shown ? `the backend decides (${EFFORT_LABELS[shown]})` : "the backend decides" }, ...(efforts ?? EFFORT_LEVELS).map((level) => ({ value: level, label: EFFORT_LABELS[level], diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index f3decd82..6066408b 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -856,6 +856,11 @@ const EffortPicker: Component<{ sessionId: string; current?: EffortLevel; pendin const entry = () => modelCatalog().find((m) => m.value === props.model) ?? (props.model ? undefined : modelCatalog().find((m) => m.isDefault)); const known = () => entry()?.efforts; const levels = (): readonly EffortLevel[] => known() ?? EFFORT_LEVELS; + // What Default means here — unless the model's known levels don't include it. + const shownDefault = () => { + const d = props.sessionDefault ?? entry()?.defaultEffort; + return d && (!known() || known()!.includes(d)) ? d : undefined; + }; const choose = (effort: EffortLevel | null) => act.run({ type: "session.set_effort", id: newRequestId(), sessionId: props.sessionId, effort }, () => setOpen(false)); return ( @@ -893,7 +898,7 @@ const EffortPicker: Component<{ sessionId: string; current?: EffortLevel; pendin > Default - + {(d) => · {EFFORT_LABELS[d()]}} From 751a46ea9b5d6b0d8f0c33336a337c27dd5ebce3 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 22:32:16 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20#417=20round=209=20=E2=80=94=20wha?= =?UTF-8?q?t=20the=20Gemini=20CLI=20or=20pi=20writes=20in=20its=20overlay?= =?UTF-8?q?=20goes=20back=20to=20the=20user's=20home;=20no=20stale=20resta?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings overlays adopt what the backend wrote in them (a first sign-in, a file saved by replacing our link): moved into the user's home before the next start, unless the user's own is newer. - GEMINI_CLI_HOME must be absolute, as PI_CODING_AGENT_DIR already is. - A Gemini CLI restart still to come is dropped by a backend switch, and by changing back to the level the backend runs at. - Removed relative/pi/auth.json, a test leftover from before round 8's guard. - Docs: Codex's Default takes the configured effort only for a model that takes it; what the picker shows before a first turn; overlay adoption and the default-home overlays that stay. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 5 +- relative/pi/auth.json | 1 - src/daemon/account-apply.ts | 64 +++++++++++++++++++++-- src/daemon/session.ts | 6 ++- src/tests/thinking-effort.test.ts | 85 ++++++++++++++++++++++++++----- 5 files changed, 142 insertions(+), 19 deletions(-) delete mode 100644 relative/pi/auth.json diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index cddf9e92..20316bdb 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -7,7 +7,7 @@ How hard a session's model thinks before it answers, on one scale for every back **Default, Off, Minimal, Low, Medium, High, Extra high (`xhigh`), Max.** **Default** is the backend's own choice, as it always was. -Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (project config included), else the running model's own default; pi gets the default in your pi settings (`defaultThinkingLevel`, else Medium; a project's `.pi/settings.json` isn't read, since pi honours it only for a project you trust). +Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (project config included) if the running model takes it, else that model's own default; pi gets the default in your pi settings (`defaultThinkingLevel`, else Medium; a project's `.pi/settings.json` isn't read, since pi honours it only for a project you trust). On Codex, a model its catalog doesn't list, with no effort configured, has no known default: Default then sends nothing, so a level set earlier on that thread stays until the next thread. Every other level is mapped to the backend's own setting (below). A level a backend has no exact match for goes to its nearest one; pi clamps a level a model doesn't take to the nearest it does. @@ -34,6 +34,7 @@ Where the backend says, the pickers offer only the model's own levels: The others (OpenAI and OpenAI-compatible endpoints, llama.cpp, Qwen, Gemini, the Gemini CLI) don't, so every level is offered and marked "support unknown". The Default entry shows what Default means on that session once the backend has said (Codex: your configured effort, the project's included, on the model it actually runs; pi: your default). +Before a session's first turn, and after a model change until the next one, it shows what the model list says instead; on Codex that reads your user config only, so a project's own `model_reasoning_effort` shows from the first turn. A level the server refuses fails the turn with a note saying so; pick another, or Default. ## When a change applies @@ -72,6 +73,8 @@ Notes: - **pi** saves a thinking-level change as your global default in its settings, so codeoid runs it from an overlay of its agent directory: your logins and sessions are linked, and `settings.json` is a fresh copy at each start, so one session's effort never changes your own pi or other sessions. The overlays live under `~/.codeoid/accounts/pi-session/`. pi's OAuth refresh locks the real `auth.json`, so codeoid's pi and one you run yourself refresh in turn; only a sign-in or sign-out made in both at the same moment isn't serialized. +- Anything the Gemini CLI or pi writes in its overlay while it runs (a first sign-in, say) is moved to your own home before the next start, as it would have been written there without codeoid; if yours is newer, it wins. - The Gemini CLI and pi overlays of an account's home are removed with the account. + Those of your own home stay, each with a copy of your Gemini CLI or pi settings; like everything under `~/.codeoid/accounts/`, agents can't read them. - **At Default**, a backend reads its own config as it always did: Qwen applies a project's `.qwen/settings.json` reasoning effort, Claude your Claude Code effort setting (your own, or the project's `.claude/settings.json`). A level you set overrides them. - **A project's own Gemini CLI settings** (`.gemini/settings.json` in the workdir) outrank your user settings, so a project that redefines `chat-base-2.5` or `chat-base-3` there overrides the effort on the Gemini CLI. diff --git a/relative/pi/auth.json b/relative/pi/auth.json deleted file mode 100644 index 9e26dfee..00000000 --- a/relative/pi/auth.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index d694ec7f..f35e1673 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -12,7 +12,7 @@ */ import { spawn } from "node:child_process"; -import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -38,6 +38,15 @@ export interface OverlaySpec { * never resume across accounts. */ shared?: readonly string[]; + /** + * Nothing in the target is its own (#417's settings overlays): a file the + * vendor created there while it ran — a first sign-in, say, or a file it + * saved by replacing our link — is moved into the source, where it would + * have gone without the overlay, unless the source's own is newer (then + * it's dropped for a link). A directory the source has too is left alone + * rather than merged. + */ + adopt?: boolean; } /** @@ -53,6 +62,7 @@ export function ensureOverlayHome(spec: OverlaySpec): void { const copies = spec.copies ?? {}; const source = resolve(spec.source); for (const dir of spec.shared ?? []) mkdirSync(join(source, dir), { recursive: true, mode: 0o700 }); + if (spec.adopt) adoptIntoSource(spec.target, source, own, copies); let entries: string[] = []; try { entries = readdirSync(source); @@ -106,6 +116,42 @@ export function ensureOverlayHome(spec: OverlaySpec): void { } } +/** {@link OverlaySpec.adopt}: hand what the vendor wrote into `target` back to `source`. */ +function adoptIntoSource(target: string, source: string, own: ReadonlySet, copies: Record): void { + let names: string[] = []; + try { + names = readdirSync(target); + } catch { + return; + } + for (const name of names) { + if (own.has(name) || name in copies || isLockEntry(target, name)) continue; + const entry = join(target, name); + let stat: ReturnType; + try { + stat = lstatSync(entry); + } catch { + continue; + } + if (stat.isSymbolicLink()) continue; + const there = join(source, name); + let theirs: ReturnType | null = null; + try { + theirs = statSync(there); + } catch { + // The source has none. + } + if (theirs && stat.isDirectory()) continue; + try { + // The later write wins, as it would have in one home. + if (!theirs || stat.mtimeMs > theirs.mtimeMs) renameSync(entry, there); + else unlinkSync(entry); // linked to the source's below + } catch { + // Another filesystem, or the source changed meanwhile: next time. + } + } +} + /** Remove from `env` every name `drop` matches. Pure. */ function without(env: Record, drop: (name: string) => boolean): Record { const out: Record = {}; @@ -226,6 +272,16 @@ export function defaultGeminiCliRoot(env: Record = p return env.GEMINI_CLI_HOME || env.HOME || homedir(); } +/** + * `root`, refused unless absolute: the CLI reads a relative GEMINI_CLI_HOME + * against each session's workdir, an overlay would build it under the + * daemon's own — neither is a home anyone meant. + */ +function absoluteGeminiCliRoot(root: string): string { + if (!isAbsolute(root)) throw new AccountError(`GEMINI_CLI_HOME must be an absolute path (it's ${JSON.stringify(root)})`); + return root; +} + /** The Gemini CLI's own Google login files — never linked into an API-key account's home. */ const GEMINI_CLI_OWN = ["oauth_creds.json", "google_accounts.json", "gemini-credentials.json"]; @@ -251,7 +307,7 @@ export function geminiAccountSettings(source: string | undefined, authType: stri export function ensureGeminiCliHome(account: Pick): void { mkdirSync(account.home, { recursive: true, mode: 0o700 }); ensureOverlayHome({ - source: join(defaultGeminiCliRoot(), ".gemini"), + source: join(absoluteGeminiCliRoot(defaultGeminiCliRoot()), ".gemini"), target: join(account.home, ".gemini"), own: GEMINI_CLI_OWN, copies: { "settings.json": (src) => geminiAccountSettings(src, "gemini-api-key") }, @@ -285,7 +341,7 @@ export function applyGeminiCliEffort( homesRoot: string = join(getConfigDir(), "accounts"), ): Record { if (!effort) return env; - const base = env.GEMINI_CLI_HOME || defaultGeminiCliRoot(); + const base = absoluteGeminiCliRoot(env.GEMINI_CLI_HOME || defaultGeminiCliRoot()); const key = `${overlayKey(base)}-${effort}`; const home = join(homesRoot, "gemini-cli-effort", key); mkdirSync(home, { recursive: true, mode: 0o700 }); @@ -295,6 +351,7 @@ export function applyGeminiCliEffort( own: [], copies: { "settings.json": (src) => geminiEffortSettings(src, effort) }, shared: ["tmp"], + adopt: true, }); return { ...env, GEMINI_CLI_HOME: home }; } @@ -435,6 +492,7 @@ export function applyPiSettingsOverlay( own: [], copies: { "settings.json": (src) => src ?? "{}\n" }, shared: ["sessions"], + adopt: true, }); return { ...env, PI_CODING_AGENT_DIR: home }; } diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 21d039c5..5380123a 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -1819,6 +1819,7 @@ export class Session { } this.#provider = this.#createProvider(requested); + this.#effortRestartPending = false; // the new backend starts with the session's effort this.#appliedAccount = this.#accountState(requested); this.#resetMcpState({ backendChanged: true }); this.#provider.onSessionEvent = (e) => this.#onSessionScopedEvent(e); @@ -5096,8 +5097,9 @@ export class Session { // Nothing new — but a change still on its way says so. applies = this.#effortRestartPending ? "restart" : this.#effortSent !== undefined && this.#effortSent !== effort ? "next-turn" : "now"; } else if (this.#provider.effortChange === "restart") { - this.#effortRestartPending = true; - applies = "restart"; + // Back to what the running backend has (changed and changed back before a turn): nothing to restart for. + this.#effortRestartPending = this.#effortSent === undefined || this.#effortSent !== effort; + applies = this.#effortRestartPending ? "restart" : "now"; } else if (this.#provider.applyEffort) { try { if (await this.#provider.applyEffort(effort)) { diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index 9e2f4626..6ab062a5 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { Database } from "bun:sqlite"; -import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseClientMessage } from "@highflame/codeoid-protocol/schemas"; @@ -95,6 +95,40 @@ describe("each backend's reading of the scale", () => { expect(applyGeminiCliEffort("low", { GEMINI_CLI_HOME: base }, homes).GEMINI_CLI_HOME).not.toBe(home); expect(applyGeminiCliEffort(undefined, { GEMINI_CLI_HOME: base }, homes)).toEqual({ GEMINI_CLI_HOME: base }); }); + + it("what the Gemini CLI writes in the overlay goes back to its usual home — a first sign-in isn't stranded there", () => { + const base = join(tmp, "base"); + mkdirSync(join(base, ".gemini"), { recursive: true }); + writeFileSync(join(base, ".gemini", "google_accounts.json"), "old"); + utimesSync(join(base, ".gemini", "google_accounts.json"), new Date(1000), new Date(1000)); // the file clock is coarse: make "older" certain + const homes = join(tmp, "homes"); + const g = join(applyGeminiCliEffort("high", { GEMINI_CLI_HOME: base }, homes).GEMINI_CLI_HOME!, ".gemini"); + // A first sign-in, written in the overlay; a file saved by replacing our link; one older than the user's own; a directory both have. + writeFileSync(join(g, "oauth_creds.json"), "signed-in"); + rmSync(join(g, "google_accounts.json")); + writeFileSync(join(g, "google_accounts.json"), "new"); + writeFileSync(join(base, ".gemini", "installation_id"), "users"); + writeFileSync(join(g, "installation_id"), "stale"); + utimesSync(join(g, "installation_id"), new Date(0), new Date(0)); + mkdirSync(join(base, ".gemini", "history"), { recursive: true }); + utimesSync(join(base, ".gemini", "history"), new Date(1000), new Date(1000)); + mkdirSync(join(g, "history", "x"), { recursive: true }); + applyGeminiCliEffort("high", { GEMINI_CLI_HOME: base }, homes); + expect(readFileSync(join(base, ".gemini", "oauth_creds.json"), "utf8")).toBe("signed-in"); + expect(readlinkSync(join(g, "oauth_creds.json"))).toBe(join(base, ".gemini", "oauth_creds.json")); + expect(readFileSync(join(base, ".gemini", "google_accounts.json"), "utf8")).toBe("new"); + expect(lstatSync(join(g, "google_accounts.json")).isSymbolicLink()).toBe(true); + expect(readFileSync(join(base, ".gemini", "installation_id"), "utf8")).toBe("users"); + expect(lstatSync(join(g, "installation_id")).isSymbolicLink()).toBe(true); + expect(existsSync(join(g, "history", "x"))).toBe(true); // not merged + expect(lstatSync(join(g, "settings.json")).isSymbolicLink()).toBe(false); // the overlay's own + }); + + it("refuses a GEMINI_CLI_HOME that isn't absolute, building nothing under the daemon's directory", () => { + const rel = `codeoid-effort-rel-${process.pid}`; + expect(() => applyGeminiCliEffort("high", { GEMINI_CLI_HOME: rel }, join(tmp, "homes"))).toThrow(/absolute/); + expect(existsSync(rel)).toBe(false); + }); }); describe("pi's settings overlay", () => { @@ -108,11 +142,14 @@ describe("pi's settings overlay", () => { expect(lstatSync(join(home, "settings.json")).isSymbolicLink()).toBe(false); expect(() => lstatSync(join(home, "auth.json.lock"))).toThrow(); expect(piDefaultThinkingLevel({ PI_CODING_AGENT_DIR: base })).toBe("high"); - // An overlay that took an empty auth.json of its own before gets the user's linked instead. + // An overlay that took an empty auth.json of its own before gets the user's linked instead — never written over it. + writeFileSync(join(base, "auth.json"), '{"login":1}'); + utimesSync(join(base, "auth.json"), new Date(1000), new Date(1000)); // the overlay's empty one is the newer rmSync(join(home, "auth.json")); writeFileSync(join(home, "auth.json"), "{}"); applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: base }, homes); expect(lstatSync(join(home, "auth.json")).isSymbolicLink()).toBe(true); + expect(readFileSync(join(base, "auth.json"), "utf8")).toBe('{"login":1}'); }); it("a dangling auth.json link is left to pi; a permanent lock FILE (codex's) is still linked", () => { @@ -137,6 +174,7 @@ describe("pi's settings overlay", () => { it("refuses a pi agent dir that isn't absolute — it would put pi's logins under the daemon's own directory", () => { expect(() => applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: "relative/pi" }, join(tmp, "homes"))).toThrow(/absolute/); expect(() => applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: "file://somehost/x" }, join(tmp, "homes"))).toThrow(/absolute/); + expect(existsSync("relative")).toBe(false); }); it("reads a ~ path as pi does", () => { @@ -189,19 +227,26 @@ class RestartProvider extends MockSessionProvider { readonly effortChange = "restart" as const; } -function manager(make: () => MockSessionProvider, providerId = "claude", defaultEffort?: EffortLevel): { m: SessionManager; store: Store; providers: MockSessionProvider[] } { +function manager( + make: () => MockSessionProvider, + providerId = "claude", + defaultEffort?: EffortLevel, + other?: { id: string; make: () => MockSessionProvider }, +): { m: SessionManager; store: Store; providers: MockSessionProvider[] } { const store = new Store(join(tmp, "codeoid.db")); const providers: MockSessionProvider[] = []; const registry = new ProviderRegistry(providerId); - registry.register({ - id: providerId, - displayName: providerId, - create: () => { - const p = make(); - providers.push(p); - return p; - }, - }); + for (const [id, mk] of [[providerId, make], ...(other ? [[other.id, other.make] as const] : [])] as const) { + registry.register({ + id, + displayName: id, + create: () => { + const p = mk(); + providers.push(p); + return p; + }, + }); + } const config = { daemonUrl: "ws://127.0.0.1:7400", dbPath: join(tmp, "codeoid.db"), @@ -310,6 +355,22 @@ describe("a session's thinking effort", () => { // The same level again while a restart is still to come says so. expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "restart" } }); expect(await setEffort(m, sid, "low")).toMatchObject({ data: { applies: "restart" } }); + // Changed back to what it runs at before a turn: nothing to restart for. + expect(await setEffort(m, sid, "high")).toMatchObject({ data: { applies: "now" } }); + expect(m._sessionForTest(sid)!.toInfo().effortPending).toBeUndefined(); + await turn(m, sid); + expect(providers[0]!.teardownCount).toBe(1); + }); + + it("a restart still to come is dropped by a backend switch — the new backend starts with the effort", async () => { + const { m } = manager(() => new RestartProvider("gemini-cli", []), "gemini-cli", undefined, { id: "claude", make: () => new MockSessionProvider("claude", []) }); + const sid = await create(m); + await turn(m, sid); + expect(await setEffort(m, sid, "high")).toMatchObject({ data: { applies: "restart" } }); + await m._sessionForTest(sid)!.switchProvider("claude", auth()); + expect(await setEffort(m, sid, "high")).toMatchObject({ data: { applies: "next-turn" } }); // not a restart: it's taken per turn now + await turn(m, sid); + expect(m._sessionForTest(sid)!.toInfo().effortPending).toBeUndefined(); }); it("created at Default explicitly, a session takes the backend's default, not the config's", async () => { From 564d5cad1d90e2b499a90f4a737ac904689607f6 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 22:46:35 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20#417=20round=2010=20=E2=80=94=20ne?= =?UTF-8?q?ver=20adopt=20a=20save=20in=20flight;=20keep=20a=20pi=20login?= =?UTF-8?q?=20an=20earlier=20overlay=20held?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adoption skips temp files (the Gemini CLI's projects.json..tmp, trustedFolders.json.tmp., codeoid's own) and anything changed in the last 10s: another session's CLI may be running in the same overlay and still writing it. - A pi auth.json an earlier overlay kept as its own goes to the user's home when that has none, or only codeoid's empty stand-in, instead of being dropped for the stand-in. - A tie in mtime goes to the backend's write; across filesystems the entry is copied into place rather than left hiding the source's; a move that fails is logged. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 3 +- src/daemon/account-apply.ts | 71 +++++++++++++++++++++++-------- src/tests/thinking-effort.test.ts | 61 +++++++++++++++++++++++++- 3 files changed, 116 insertions(+), 19 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index 20316bdb..ab819a2b 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -73,7 +73,8 @@ Notes: - **pi** saves a thinking-level change as your global default in its settings, so codeoid runs it from an overlay of its agent directory: your logins and sessions are linked, and `settings.json` is a fresh copy at each start, so one session's effort never changes your own pi or other sessions. The overlays live under `~/.codeoid/accounts/pi-session/`. pi's OAuth refresh locks the real `auth.json`, so codeoid's pi and one you run yourself refresh in turn; only a sign-in or sign-out made in both at the same moment isn't serialized. -- Anything the Gemini CLI or pi writes in its overlay while it runs (a first sign-in, say) is moved to your own home before the next start, as it would have been written there without codeoid; if yours is newer, it wins. +- Anything the Gemini CLI or pi writes in its overlay while it runs (a first sign-in, say) is moved to the home the overlay is of (yours, or the account's) at a later start, as it would have been written there without codeoid; if the copy there is newer, that one wins. + A save still in progress (a temp file, or anything changed in the last 10 seconds, which another session may still be writing) waits for a later start. - The Gemini CLI and pi overlays of an account's home are removed with the account. Those of your own home stay, each with a copy of your Gemini CLI or pi settings; like everything under `~/.codeoid/accounts/`, agents can't read them. - **At Default**, a backend reads its own config as it always did: Qwen applies a project's `.qwen/settings.json` reasoning effort, Claude your Claude Code effort setting (your own, or the project's `.claude/settings.json`). A level you set overrides them. diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index f35e1673..e0d26b1a 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -12,7 +12,7 @@ */ import { spawn } from "node:child_process"; -import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -116,6 +116,19 @@ export function ensureOverlayHome(spec: OverlaySpec): void { } } +/** + * A save in flight: vendors write a temp file and rename it into place + * (the Gemini CLI's `projects.json..tmp`, `trustedFolders.json.tmp.`, + * codeoid's own atomicWrite). Never adopted — the save completes in the overlay. + */ +const TEMP_NAME = /\.tmp($|[.-])/; + +/** + * How long an entry must have gone unchanged before it's adopted: another + * session's backend may be running in the same overlay and still writing it. + */ +export const ADOPT_SETTLE_MS = 10_000; + /** {@link OverlaySpec.adopt}: hand what the vendor wrote into `target` back to `source`. */ function adoptIntoSource(target: string, source: string, own: ReadonlySet, copies: Record): void { let names: string[] = []; @@ -125,7 +138,7 @@ function adoptIntoSource(target: string, source: string, own: ReadonlySet; try { @@ -133,7 +146,7 @@ function adoptIntoSource(target: string, source: string, own: ReadonlySet | null = null; try { @@ -143,15 +156,34 @@ function adoptIntoSource(target: string, source: string, own: ReadonlySet theirs.mtimeMs) renameSync(entry, there); + // The later write wins, as it would have in one home; a tie goes to the backend's (it can't be told apart, and it's the one codeoid ran). + if (!theirs || stat.mtimeMs >= theirs.mtimeMs) moveEntry(entry, there, stat.isDirectory()); else unlinkSync(entry); // linked to the source's below - } catch { - // Another filesystem, or the source changed meanwhile: next time. + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + // Left in the overlay, where it hides the source's: say so rather than keep it quietly. + console.warn(`[codeoid/accounts] couldn't move ${entry} to ${there}: ${err instanceof Error ? err.message : String(err)}`); + } } } } +/** Rename `from` to `to`; across filesystems, copy beside `to`, rename that into place, then remove `from`. */ +function moveEntry(from: string, to: string, isDir: boolean): void { + try { + renameSync(from, to); + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err; + } + const staged = `${to}.codeoid-${process.pid}.tmp`; + rmSync(staged, { recursive: true, force: true }); + if (isDir) cpSync(from, staged, { recursive: true, verbatimSymlinks: true }); + else copyFileSync(from, staged); + renameSync(staged, to); + rmSync(from, { recursive: true, force: true }); +} + /** Remove from `env` every name `drop` matches. Pure. */ function without(env: Record, drop: (name: string) => boolean): Record { const out: Record = {}; @@ -466,10 +498,23 @@ export function applyPiSettingsOverlay( const base = piAgentDirOf(env); // A relative (or unparseable) dir would put pi's logins under the daemon's own working directory. if (!isAbsolute(base)) throw new AccountError(`PI_CODING_AGENT_DIR must be an absolute path (it's ${JSON.stringify(base)})`); - // pi creates its auth.json when it's missing — then it must be the user's, - // not a file of the overlay's own that a later `pi /login` never reaches. mkdirSync(base, { recursive: true, mode: 0o700 }); const auth = join(base, "auth.json"); + const home = join(homesRoot, "pi-session", overlayKey(base)); + // An overlay built before this took an auth.json of its own: an empty one + // gives way to the user's; a login goes to the user's home, unless the user + // has one there already (an empty one is just the stand-in made below). + const own = join(home, "auth.json"); + try { + if (!lstatSync(own).isSymbolicLink()) { + if (readFileSync(own, "utf8").trim() === "{}") unlinkSync(own); + else if (!existsSync(auth) || readFileSync(auth, "utf8").trim() === "{}") moveEntry(own, auth, false); + } + } catch { + // None yet. + } + // pi creates its auth.json when it's missing — then it must be the user's, + // not a file of the overlay's own that a later `pi /login` never reaches. if (!existsSync(auth)) { try { writeFileSync(auth, "{}", { mode: 0o600, flag: "wx" }); // never through a planted link @@ -478,14 +523,6 @@ export function applyPiSettingsOverlay( if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; } } - const home = join(homesRoot, "pi-session", overlayKey(base)); - // An overlay built before that took an empty one of its own: link the user's instead. - try { - const own = join(home, "auth.json"); - if (!lstatSync(own).isSymbolicLink() && readFileSync(own, "utf8").trim() === "{}") unlinkSync(own); - } catch { - // None yet. - } ensureOverlayHome({ source: base, target: home, diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index 6ab062a5..a48733d0 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { Database } from "bun:sqlite"; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseClientMessage } from "@highflame/codeoid-protocol/schemas"; @@ -113,17 +113,59 @@ describe("each backend's reading of the scale", () => { mkdirSync(join(base, ".gemini", "history"), { recursive: true }); utimesSync(join(base, ".gemini", "history"), new Date(1000), new Date(1000)); mkdirSync(join(g, "history", "x"), { recursive: true }); + // A save still in flight, and a file another session's CLI is still writing: neither moved. + writeFileSync(join(g, "projects.json.0b1c.tmp"), "{"); + writeFileSync(join(g, "trustedFolders.json.tmp.0b1c"), "{"); + writeFileSync(join(g, "fresh.json"), "writing"); + const settled = new Date(Date.now() - 60_000); + for (const n of ["oauth_creds.json", "google_accounts.json", "projects.json.0b1c.tmp", "trustedFolders.json.tmp.0b1c"]) utimesSync(join(g, n), settled, settled); applyGeminiCliEffort("high", { GEMINI_CLI_HOME: base }, homes); + for (const n of ["projects.json.0b1c.tmp", "trustedFolders.json.tmp.0b1c", "fresh.json"]) { + expect(lstatSync(join(g, n)).isFile()).toBe(true); + expect(existsSync(join(base, ".gemini", n))).toBe(false); + } expect(readFileSync(join(base, ".gemini", "oauth_creds.json"), "utf8")).toBe("signed-in"); expect(readlinkSync(join(g, "oauth_creds.json"))).toBe(join(base, ".gemini", "oauth_creds.json")); expect(readFileSync(join(base, ".gemini", "google_accounts.json"), "utf8")).toBe("new"); expect(lstatSync(join(g, "google_accounts.json")).isSymbolicLink()).toBe(true); expect(readFileSync(join(base, ".gemini", "installation_id"), "utf8")).toBe("users"); + // Written in the same tick of a coarse file clock: the backend's write wins — it can't be told apart, and dropping it could lose a refreshed login. + writeFileSync(join(base, ".gemini", "tie.json"), "users"); + rmSync(join(g, "tie.json"), { force: true }); + writeFileSync(join(g, "tie.json"), "backend"); + utimesSync(join(base, ".gemini", "tie.json"), settled, settled); + utimesSync(join(g, "tie.json"), settled, settled); + applyGeminiCliEffort("high", { GEMINI_CLI_HOME: base }, homes); + expect(readFileSync(join(base, ".gemini", "tie.json"), "utf8")).toBe("backend"); expect(lstatSync(join(g, "installation_id")).isSymbolicLink()).toBe(true); expect(existsSync(join(g, "history", "x"))).toBe(true); // not merged expect(lstatSync(join(g, "settings.json")).isSymbolicLink()).toBe(false); // the overlay's own }); + it.skipIf(!existsSync("/dev/shm") || statSync("/dev/shm").dev === statSync(tmpdir()).dev)( + "across filesystems, what the CLI wrote is copied to its usual home rather than left in the overlay", + () => { + const base = mkdtempSync(join("/dev/shm", "codeoid-effort-")); + try { + mkdirSync(join(base, ".gemini"), { recursive: true }); + const homes = join(tmp, "homes"); + const g = join(applyGeminiCliEffort("low", { GEMINI_CLI_HOME: base }, homes).GEMINI_CLI_HOME!, ".gemini"); + writeFileSync(join(g, "oauth_creds.json"), "signed-in"); + mkdirSync(join(g, "extensions", "e"), { recursive: true }); + const settled = new Date(Date.now() - 60_000); + utimesSync(join(g, "oauth_creds.json"), settled, settled); + utimesSync(join(g, "extensions"), settled, settled); + applyGeminiCliEffort("low", { GEMINI_CLI_HOME: base }, homes); + expect(readFileSync(join(base, ".gemini", "oauth_creds.json"), "utf8")).toBe("signed-in"); + expect(existsSync(join(base, ".gemini", "extensions", "e"))).toBe(true); + expect(lstatSync(join(g, "oauth_creds.json")).isSymbolicLink()).toBe(true); + expect(lstatSync(join(g, "extensions")).isSymbolicLink()).toBe(true); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }, + ); + it("refuses a GEMINI_CLI_HOME that isn't absolute, building nothing under the daemon's directory", () => { const rel = `codeoid-effort-rel-${process.pid}`; expect(() => applyGeminiCliEffort("high", { GEMINI_CLI_HOME: rel }, join(tmp, "homes"))).toThrow(/absolute/); @@ -152,6 +194,23 @@ describe("pi's settings overlay", () => { expect(readFileSync(join(base, "auth.json"), "utf8")).toBe('{"login":1}'); }); + it("a login an earlier overlay kept as its own goes to the user's home, never under codeoid's empty stand-in", () => { + const homes = join(tmp, "homes"); + for (const [dir, theirs] of [["pi-a", null], ["pi-b", "{}"], ["pi-c", '{"mine":1}']] as const) { + const base = join(tmp, dir); + const home = applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: base }, homes).PI_CODING_AGENT_DIR!; + rmSync(join(home, "auth.json")); + writeFileSync(join(home, "auth.json"), '{"overlay":1}'); + if (theirs === null) rmSync(join(base, "auth.json")); + else { + writeFileSync(join(base, "auth.json"), theirs); + utimesSync(join(base, "auth.json"), new Date(), new Date(Date.now() + 60_000)); // newer, as the stand-in always is + } + applyPiSettingsOverlay({ PI_CODING_AGENT_DIR: base }, homes); + expect(readFileSync(join(base, "auth.json"), "utf8")).toBe(theirs === '{"mine":1}' ? theirs : '{"overlay":1}'); + } + }); + it("a dangling auth.json link is left to pi; a permanent lock FILE (codex's) is still linked", () => { const base = join(tmp, "pi2"); mkdirSync(base, { recursive: true }); From 765ef4f625e9ed33c8867edb546aa9fb9222a23b Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 22:56:56 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20#417=20round=2011=20=E2=80=94=20ov?= =?UTF-8?q?erlay=20hand-back=20moves=20only=20files=20and=20directories,?= =?UTF-8?q?=20and=20never=20leaves=20a=20partial=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only regular files and directories are handed back: copying a FIFO across filesystems would block the daemon. - A file and a directory of one name never replace each other. - The cross-filesystem copy is staged under a fresh name, created exclusively (never through a planted link), and removed if the copy or the rename into place fails. - A move that keeps failing is logged once per entry, not at every start. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/daemon/account-apply.ts | 32 +++++++++++++++++++++---------- src/tests/thinking-effort.test.ts | 24 ++++++++++++++++++++++- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/daemon/account-apply.ts b/src/daemon/account-apply.ts index e0d26b1a..c8641f9d 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -12,7 +12,8 @@ */ import { spawn } from "node:child_process"; -import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { chmodSync, constants as fsConstants, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -146,7 +147,8 @@ function adoptIntoSource(target: string, source: string, own: ReadonlySet | null = null; try { @@ -154,20 +156,25 @@ function adoptIntoSource(target: string, source: string, own: ReadonlySet= theirs.mtimeMs) moveEntry(entry, there, stat.isDirectory()); else unlinkSync(entry); // linked to the source's below } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - // Left in the overlay, where it hides the source's: say so rather than keep it quietly. + if ((err as NodeJS.ErrnoException).code !== "ENOENT" && !adoptWarned.has(entry)) { + // Left in the overlay, where it hides the source's: say so (once) rather than keep it quietly. + adoptWarned.add(entry); console.warn(`[codeoid/accounts] couldn't move ${entry} to ${there}: ${err instanceof Error ? err.message : String(err)}`); } } } } +/** Overlay entries a failed move was already logged for. */ +const adoptWarned = new Set(); + /** Rename `from` to `to`; across filesystems, copy beside `to`, rename that into place, then remove `from`. */ function moveEntry(from: string, to: string, isDir: boolean): void { try { @@ -176,11 +183,16 @@ function moveEntry(from: string, to: string, isDir: boolean): void { } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err; } - const staged = `${to}.codeoid-${process.pid}.tmp`; - rmSync(staged, { recursive: true, force: true }); - if (isDir) cpSync(from, staged, { recursive: true, verbatimSymlinks: true }); - else copyFileSync(from, staged); - renameSync(staged, to); + // A fresh name, created exclusively: never written through a link planted there. + const staged = `${to}.codeoid-${randomUUID()}.tmp`; + try { + if (isDir) cpSync(from, staged, { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false }); + else copyFileSync(from, staged, fsConstants.COPYFILE_EXCL); + renameSync(staged, to); + } catch (err) { + rmSync(staged, { recursive: true, force: true }); // no partial copy left in the source + throw err; + } rmSync(from, { recursive: true, force: true }); } diff --git a/src/tests/thinking-effort.test.ts b/src/tests/thinking-effort.test.ts index a48733d0..fe762356 100644 --- a/src/tests/thinking-effort.test.ts +++ b/src/tests/thinking-effort.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { Database } from "bun:sqlite"; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseClientMessage } from "@highflame/codeoid-protocol/schemas"; @@ -160,12 +160,34 @@ describe("each backend's reading of the scale", () => { expect(existsSync(join(base, ".gemini", "extensions", "e"))).toBe(true); expect(lstatSync(join(g, "oauth_creds.json")).isSymbolicLink()).toBe(true); expect(lstatSync(join(g, "extensions")).isSymbolicLink()).toBe(true); + // A file where the home has a directory of that name: left, with no partial copy in the home. + mkdirSync(join(base, ".gemini", "x")); + utimesSync(join(base, ".gemini", "x"), new Date(1000), new Date(1000)); + rmSync(join(g, "x"), { force: true }); + writeFileSync(join(g, "x"), "file"); + utimesSync(join(g, "x"), settled, settled); + applyGeminiCliEffort("low", { GEMINI_CLI_HOME: base }, homes); + expect(lstatSync(join(g, "x")).isFile()).toBe(true); + expect(readdirSync(join(base, ".gemini")).filter((n) => n.endsWith(".tmp"))).toEqual([]); } finally { rmSync(base, { recursive: true, force: true }); } }, ); + it("only files and directories are handed back — a pipe left in the overlay stays there", () => { + const base = join(tmp, "fifo"); + mkdirSync(join(base, ".gemini"), { recursive: true }); + const homes = join(tmp, "homes"); + const g = join(applyGeminiCliEffort("low", { GEMINI_CLI_HOME: base }, homes).GEMINI_CLI_HOME!, ".gemini"); + expect(Bun.spawnSync(["mkfifo", join(g, "pipe")]).exitCode).toBe(0); + const settled = new Date(Date.now() - 60_000); + utimesSync(join(g, "pipe"), settled, settled); + applyGeminiCliEffort("low", { GEMINI_CLI_HOME: base }, homes); + expect(lstatSync(join(g, "pipe")).isFIFO()).toBe(true); + expect(existsSync(join(base, ".gemini", "pipe"))).toBe(false); + }); + it("refuses a GEMINI_CLI_HOME that isn't absolute, building nothing under the daemon's directory", () => { const rel = `codeoid-effort-rel-${process.pid}`; expect(() => applyGeminiCliEffort("high", { GEMINI_CLI_HOME: rel }, join(tmp, "homes"))).toThrow(/absolute/); From 99cbc3e295e239039188479e5419bbc834be55f7 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 24 Oct 2026 23:03:31 +0800 Subject: [PATCH 13/13] docs: #417 Codex's Default honours a project's effort only for a trusted project, as Codex does Checked live against codex 0.154: config/read with an untrusted cwd returns the user's model_reasoning_effort and ignores the project's; with the project trusted, it returns the project's. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/thinking-effort.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/thinking-effort.md b/docs/thinking-effort.md index ab819a2b..3d447902 100644 --- a/docs/thinking-effort.md +++ b/docs/thinking-effort.md @@ -7,7 +7,7 @@ How hard a session's model thinks before it answers, on one scale for every back **Default, Off, Minimal, Low, Medium, High, Extra high (`xhigh`), Max.** **Default** is the backend's own choice, as it always was. -Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (project config included) if the running model takes it, else that model's own default; pi gets the default in your pi settings (`defaultThinkingLevel`, else Medium; a project's `.pi/settings.json` isn't read, since pi honours it only for a project you trust). +Most backends are simply sent nothing; two keep a level once it's set, so Default is sent to them explicitly, as the level they would pick unasked: Codex gets your configured `model_reasoning_effort` (a project's `.codex/config.toml` only if you trust the project, as Codex itself reads it) if the running model takes it, else that model's own default; pi gets the default in your pi settings (`defaultThinkingLevel`, else Medium; a project's `.pi/settings.json` isn't read, since pi honours it only for a project you trust). On Codex, a model its catalog doesn't list, with no effort configured, has no known default: Default then sends nothing, so a level set earlier on that thread stays until the next thread. Every other level is mapped to the backend's own setting (below). A level a backend has no exact match for goes to its nearest one; pi clamps a level a model doesn't take to the nearest it does. @@ -33,7 +33,7 @@ Where the backend says, the pickers offer only the model's own levels: - pi reports which models reason and which levels each takes. The others (OpenAI and OpenAI-compatible endpoints, llama.cpp, Qwen, Gemini, the Gemini CLI) don't, so every level is offered and marked "support unknown". -The Default entry shows what Default means on that session once the backend has said (Codex: your configured effort, the project's included, on the model it actually runs; pi: your default). +The Default entry shows what Default means on that session once the backend has said (Codex: your configured effort, a trusted project's included, on the model it actually runs; pi: your default). Before a session's first turn, and after a model change until the next one, it shows what the model list says instead; on Codex that reads your user config only, so a project's own `model_reasoning_effort` shows from the first turn. A level the server refuses fails the turn with a note saying so; pick another, or Default.