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..3d447902 --- /dev/null +++ b/docs/thinking-effort.md @@ -0,0 +1,81 @@ +# 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** 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` (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. + +## 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". +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. + +## When a change applies + +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. + +## What each level means on each backend + +| 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 (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/`. + 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 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. +- **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.test.ts b/packages/core/src/slash.test.ts index 9488e5ec..08bfc283 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,18 @@ 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."); + 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", () => { expect(parseSlash("/model")).toEqual({ kind: "model-picker" }); }); diff --git a/packages/core/src/slash.ts b/packages/core/src/slash.ts index 64e681f9..6286de5e 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,32 @@ 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 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/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..d36d1c68 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).nullable().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..a557242d 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 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]; + export const LIMITS = { /** Max `session.send.text` length. ~1M chars ≈ hundreds of thousands of tokens. */ SEND_TEXT_MAX: 1_000_000, @@ -477,6 +485,22 @@ 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; + /** + * 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 ") @@ -1094,6 +1118,7 @@ export type ClientMessage = | SessionRotateMsg | SessionSearchMsg | SessionSetModelMsg + | SessionSetEffortMsg | SessionSetProviderMsg | SessionMcpReconnectMsg | SessionMcpRefreshMsg @@ -1318,6 +1343,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; 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 @@ -1817,6 +1844,30 @@ 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 (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; +} + /** * Full-text + semantic search across ALL sessions in a workspace — the * human-facing counterpart to Claude's `recall()` tool. Searches over @@ -1993,6 +2044,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..250ff53f 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, or default for the backend's own (omitted: 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 | null | undefined; + if (opts.effort) { + try { + 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); + } + } const client = new TerminalClient(config); await client.connect(); await client.createSession(name, resolvedWorkdir, { providerId: opts.provider, model: opts.model, + ...(effort !== undefined ? { 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..c8641f9d 100644 --- a/src/daemon/account-apply.ts +++ b/src/daemon/account-apply.ts @@ -12,11 +12,14 @@ */ import { spawn } from "node:child_process"; -import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, 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, join, resolve } from "node:path"; -import { atomicWrite } from "../config.js"; -import { AccountError, type ResolvedAccount } from "./accounts.js"; +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"; +import { AccountError, overlayKey, type ResolvedAccount } from "./accounts.js"; // ── Overlay homes ───────────────────────────────────────────────────────────── @@ -36,6 +39,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; } /** @@ -51,6 +63,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); @@ -58,7 +71,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(source, name)) continue; const link = join(spec.target, name); const want = join(source, name); let stat: ReturnType | null = null; @@ -70,9 +83,14 @@ 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); + } 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; } - symlinkSync(want, link); } // 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). @@ -85,7 +103,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(source, name) || !entries.includes(name)) unlinkLink(link); } for (const [name, transform] of Object.entries(copies)) { const from = join(source, name); @@ -99,6 +117,85 @@ 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[] = []; + try { + names = readdirSync(target); + } catch { + return; + } + for (const name of names) { + if (own.has(name) || name in copies || isLockEntry(target, name) || TEMP_NAME.test(name)) continue; + const entry = join(target, name); + let stat: ReturnType; + try { + stat = lstatSync(entry); + } catch { + continue; + } + // Only files and directories: a FIFO's copy would block the daemon. + if (!(stat.isFile() || stat.isDirectory()) || Date.now() - stat.mtimeMs < ADOPT_SETTLE_MS) continue; + const there = join(source, name); + let theirs: ReturnType | null = null; + try { + theirs = statSync(there); + } catch { + // The source has none. + } + // A directory there too is never merged; a file and a directory of one name can't replace each other. + if (theirs && (stat.isDirectory() || theirs.isDirectory())) continue; + try { + // 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 (err) { + 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 { + renameSync(from, to); + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err; + } + // 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 }); +} + /** Remove from `env` every name `drop` matches. Pure. */ function without(env: Record, drop: (name: string) => boolean): Record { const out: Record = {}; @@ -219,6 +316,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"]; @@ -244,7 +351,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") }, @@ -265,6 +372,76 @@ 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 = 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 }); + ensureOverlayHome({ + source: join(base, ".gemini"), + target: join(home, ".gemini"), + own: [], + copies: { "settings.json": (src) => geminiEffortSettings(src, effort) }, + shared: ["tmp"], + adopt: true, + }); + 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 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) => ({ + 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); @@ -280,6 +457,111 @@ 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). */ +export function piAgentDirOf(env: Record): string { + const dir = env.PI_CODING_AGENT_DIR || defaultPiAgentDir(env); + 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; +} + +/** + * 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 true; // gone between the listing and now: a lock just released + } +} + +/** 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 (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'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, + 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)})`); + 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 + } 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; + } + } + ensureOverlayHome({ + source: base, + target: home, + own: [], + copies: { "settings.json": (src) => src ?? "{}\n" }, + shared: ["sessions"], + adopt: true, + }); + 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 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): 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"; +} + /** * 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 @@ -287,7 +569,9 @@ export function defaultPiAgentDir(env: Record = proc */ export function applyPiAccount(account: ResolvedAccount | null, env: Record): Record { if (!account) return env; - ensureOverlayHome({ source: defaultPiAgentDir(), 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/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/acp/index.ts b/src/daemon/providers/acp/index.ts index 6f813f90..1afae808 100644 --- a/src/daemon/providers/acp/index.ts +++ b/src/daemon/providers/acp/index.ts @@ -46,7 +46,8 @@ 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"; +import { effortRejectionHint } from "../effort.js"; export interface GeminiAcpProviderInit { sessionId: string; @@ -98,6 +99,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; @@ -213,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(); }); @@ -310,7 +314,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..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 } from "../../../protocol/types.js"; +import { type AuthContext, EFFORT_LEVELS, type 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,23 @@ 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, 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 === 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 }; + return true; + } + async dispose(): Promise { await this.teardown(); } @@ -753,7 +772,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 +796,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 +822,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 +894,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 +1107,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 +2237,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?: 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 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, + ...(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..576040ac 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, @@ -293,6 +293,16 @@ export class CodexProvider implements SessionProvider { #proc: CodexRpcProcess | null = null; #threadId: string | null = null; + /** 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. */ + #defaultEffort: string | null = null; + #modelListTried = false; #hasQueried = false; #modelsReported = false; #pendingHistorySeed: string | null = null; @@ -463,12 +473,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 } }; + 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) { @@ -644,15 +658,44 @@ 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; + isDefault?: boolean; + }>; }; + // 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 (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") - .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 } : {}), + ...(m.isDefault === true ? { isDefault: true } : {}), + }; + }); } catch { return []; } @@ -678,6 +721,49 @@ export class CodexProvider implements SessionProvider { // ── Internal ────────────────────────────────────────────────────────── + /** 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; + } + + /** 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; + if (opts.effort) return { effort: toCodexEffort(opts.effort) }; + return baseline ? { effort: baseline } : {}; + } + + /** + * 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 { + 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. + } + } + const model = opts.model ?? this.#threadModel; + 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; + } + async #startTurn(opts: TurnOpts): Promise { await this.#ensureThread(opts); const seed = this.#pendingHistorySeed; @@ -697,7 +783,15 @@ 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), and null there means "no override" — + // 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 } }; + // 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; } @@ -767,9 +861,11 @@ 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; + this.#configEffort = undefined; return; } } catch { @@ -785,8 +881,11 @@ 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; + // codex reads its config afresh for each thread; so does Default's baseline. + this.#configEffort = undefined; this.#adoptThread(started.thread.id); await this.#reportModelsOnce(); } @@ -813,10 +912,38 @@ 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. + // 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; 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. + } + // 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, + // ...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; - 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 } : {}), + ...(m.isDefault ? { isDefault: true } : {}), + })), + ); } } @@ -1353,3 +1480,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..67356170 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,25 @@ 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 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 }; + 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 }; + } + return null; +} diff --git a/src/daemon/providers/interface.ts b/src/daemon/providers/interface.ts index 3ffd7949..36ce95e1 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,11 @@ 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; + /** The model the backend runs when none is chosen. */ + isDefault?: boolean; } /** @@ -521,6 +529,12 @@ 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; + /** The model the backend runs when none is chosen, when it says (the effort picker reads its levels). */ + isDefault?: boolean; } /** @@ -597,6 +611,23 @@ 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"; + /** 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 + * 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..f3b34de6 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 { @@ -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,6 +123,9 @@ export class PiProvider implements SessionProvider { #bridgeReady = false; #hasQueried = false; #model: string | null = null; + /** pi's thinking level now (#417), and the user's own default, which Default returns to. */ + #thinkingLevel: string | null = null; + #defaultThinkingLevel: 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 = 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; 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, @@ -322,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; @@ -353,7 +361,8 @@ 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.#thinkingLevel ??= data.thinkingLevel; const file = data?.sessionFile; if (typeof file === "string" && file.length > 0 && file !== this.#backingSessionId) { this.#backingSessionId = file; @@ -396,7 +405,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 +416,37 @@ 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), })); } + /** 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; + 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 ?? "")}`); + 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 @@ -422,6 +459,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 { @@ -777,3 +816,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/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 24b575a4..40bf01e1 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,7 +1812,9 @@ mcpHub: this.#mcpHub, displayName: m.displayName, ...(m.description ? { description: m.description } : {}), ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), - isDefault: m.value === "default", + ...(m.efforts ? { efforts: m.efforts } : {}), + ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}), + isDefault: m.isDefault === true || m.value === "default", })); const previous = this.#modelsCache.get(providerId); if (previous && JSON.stringify(previous) === JSON.stringify(models)) return; @@ -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 !== undefined ? { effort: msg.effort } : {}), pack, collaboration, identityManager: this.#identityManager, @@ -4770,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, + effort: parentInfo.effort ?? null, // the parent's Default stays Default hooks: this.#hooks, providerId, forkedFrom: { @@ -7592,6 +7599,17 @@ mcpHub: this.#mcpHub, return best?.workdir ?? null; } + /** 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 }; + } + /** * Switch the model for a session. Reuses SESSION_SEND scope (same as * setMode / rotate — anyone who can drive the session can change its diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 19f40a1e..5380123a 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -85,7 +85,8 @@ 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 { 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"; @@ -420,6 +421,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 | null; /** * Observe every status transition of this session. The dispatcher uses * this to detect a worker's turn completing (→ idle/error) or wedging @@ -909,6 +915,14 @@ 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 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; + /** 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 @@ -1298,6 +1312,14 @@ 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 !== undefined + ? opts.effort + : (opts.config?.session.defaultEffort ?? null); this.#provider = opts._testProvider ?? this.#createProvider(opts.providerId); this.#appliedAccount = this.#accountState(this.#provider.id); @@ -1364,7 +1386,8 @@ 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({ sessionId: this.id, @@ -1796,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); @@ -3223,6 +3247,7 @@ export class Session { history: this.#accumulator.history, userMessage: content, model: this.#model ?? undefined, + effort: this.#turnEffort() ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#buildPromptAppend(), @@ -3258,6 +3283,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 +3338,7 @@ export class Session { history: this.#accumulator.history, userMessage: turnPrompt, model: this.#model ?? undefined, + effort: this.#turnEffort() ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#composePromptAppend(hookPromptAppend), @@ -4294,6 +4322,7 @@ export class Session { history: this.#accumulator.history, userMessage: prompt, model: this.#model ?? undefined, + effort: this.#turnEffort() ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, workdir: this.workdir, systemPromptAppend: this.#composePromptAppend(), @@ -4662,6 +4691,14 @@ export class Session { queuedMessages: this.#provider.queuedMessages, model: this.#model ?? undefined, fallbackModel: this.#fallbackModel ?? undefined, + effort: this.#effort ?? undefined, + // 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.slice(0, LIMITS.MODEL_MAX) } : {}), forkedFrom: this.forkedFrom, worktree: this.worktree, collaboration: this.collaboration, @@ -5038,6 +5075,99 @@ 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 between turns + * once no work is running (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)"}${sender.delegationDepth > 0 ? ` delegated=${sender.delegationDepth}` : ""}`, + ); + let applies: EffortChangeResult["applies"] = "next-turn"; + if (effort === prev) { + // 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") { + // 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)) { + 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" + : this.#backgroundTasks.size > 0 + ? " — 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", + effort, + prev_effort: prev, + applies, + }); + this.#persistAndBuffer(info); + this.#broadcastRaw(info); + this.#broadcastInfoUpdate(); + return { effort, applies, ...(applies === "next-turn" && this.#backgroundTasks.size > 0 ? { waitsForBackground: true } : {}) }; + } + + /** + * 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 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; + } + + /** 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; + // 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(); + } + } + /** * 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..b338f174 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,36 @@ 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 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; + 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..8c998076 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 | null; 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 !== 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 d9300dd3..797013d8 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 } } }); @@ -390,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; } @@ -416,12 +420,35 @@ 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: { + // 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, + }, + }); + break; case "model/list": send({ 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", + ...(process.env.CODEX_FAKE_LIST_DEFAULT ? { isDefault: true } : {}), + }, ], }, }); @@ -430,6 +457,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..b043da41 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" } }, ], }, }); @@ -255,8 +259,25 @@ 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": { + 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-claude.test.ts b/src/tests/provider-claude.test.ts index 5479d0a3..def6d7f5 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,85 @@ 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" }]); + // 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, "max"); + for (let i = 0; i < 20; i++) await Promise.resolve(); + expect(queryCallCount).toBe(1); + // ...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) { + } + 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..b7076c1d 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,142 @@ 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; 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 running model's own default (no configured effort) + const saved = process.env.CODEX_FAKE_CONFIG_EFFORT; + process.env.CODEX_FAKE_CONFIG_EFFORT = "high"; + try { + 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 = "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("none"); + } 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 () => { + 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" }); + }); +}); + +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 }]); + }); +}); + +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 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; + } + } + }); +}); + +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/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..f795084a 100644 --- a/src/tests/provider-pi.test.ts +++ b/src/tests/provider-pi.test.ts @@ -6,11 +6,11 @@ */ 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"; -import { PiProvider } from "../daemon/providers/pi/index.js"; +import { PiProvider, piThinkingLevels } from "../daemon/providers/pi/index.js"; import type { ProviderEvent, ToolApprovalFn, @@ -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(); @@ -411,3 +421,51 @@ 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"); // pi's own default + 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"]); + }); +}); + +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); + }); +}); + +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/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 new file mode 100644 index 00000000..fe762356 --- /dev/null +++ b/src/tests/thinking-effort.test.ts @@ -0,0 +1,568 @@ +/** + * 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 { 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"; +import type { CodeoidConfig } from "../config.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"; +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 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 }); // 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", "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(); + }); + + 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 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(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); + }); + + 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 }); + }); + + 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 }); + // 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); + // 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/); + expect(existsSync(rel)).toBe(false); + }); +}); + +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 — 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 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 }); + 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 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" })); + writeFileSync(join(work, ".pi", "settings.json"), JSON.stringify({ defaultThinkingLevel: "xhigh" })); + 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/); + expect(existsSync("relative")).toBe(false); + }); + + 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: `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"); + }); + + 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]; +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, + 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); + 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"), + 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("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 + 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 + }); + + 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 + // 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 () => { + 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); + 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("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"); + 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 () => { + 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(); + }); +}); + +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/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..656e4564 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,14 @@ 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; + /** 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; @@ -22,7 +29,7 @@ interface Props { onCancel: () => void; } -export function BackendPicker({ kind, provider, 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); @@ -43,7 +50,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, sessionDefaultEffort ?? entry?.defaultEffort)); + setLive(true); + } else if (kind === "model") { const result = await client.listModels(provider); if (cancelled) return; setChoices(result.models.map((model) => ({ @@ -66,7 +80,7 @@ export function BackendPicker({ kind, provider, sessionName, current, providers, 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 })) @@ -100,7 +114,8 @@ 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}` : ""} + {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. @@ -122,3 +137,22 @@ 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; + // 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: shown ? `the backend decides (${EFFORT_LABELS[shown]})` : "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..9ada71e5 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,27 @@ 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 ?? target.info.runningModel} + sessionDefaultEffort={target.info.defaultEffort} + effortPending={target.info.effortPending === true} 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..6066408b 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,13 @@ 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; + // 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 ( +
+ + +
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("");