feat(code-mode): per-session model + effort, and keep output on nav (#632)

Two improvements to the Code section:

- Fix: leaving the Code section and returning no longer drops the open
  session's output. The selected session id is persisted to localStorage
  (mirroring the terminal-height pattern) and restored on remount, so the
  right-hand chat pane re-binds instead of falling back to the empty state.

- Feature: choose the coding agent's model and reasoning effort per session.
  Choices are discovered live from the engine (the same list `/model` shows)
  via a new `codeMode:listModelOptions` IPC, cached per agent — never
  hardcoded, so they track whatever the provider currently offers. Claude
  exposes model + effort as separate axes (with explicit Opus/Sonnet/Haiku
  alias rows surfaced for clarity); Codex folds effort into the model id and
  reports no separate effort. Selections persist on the CodeSession and are
  re-applied to the ACP session each turn (best-effort), editable from both
  the new-session dialog and the session header.
This commit is contained in:
gagan 2026-06-21 08:38:49 -07:00 committed by GitHub
parent c8d801a123
commit 45188e7c1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 360 additions and 9 deletions

View file

@ -41,6 +41,7 @@ import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js'
import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js';
import * as codeGit from '@x/core/dist/code-mode/git/service.js';
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
@ -972,6 +973,10 @@ export function setupIpcHandlers() {
const service = container.resolve<CodeSessionService>('codeSessionService');
return { session: await service.update(args.sessionId, args.patch) };
},
'codeMode:listModelOptions': async (_event, args) => {
const manager = container.resolve<CodeModeManager>('codeModeManager');
return manager.listModelOptions(args.agent);
},
'codeSession:delete': async (_event, args) => {
const service = container.resolve<CodeSessionService>('codeSessionService');
disposeTerminal(args.sessionId);

View file

@ -0,0 +1,28 @@
import type { CodingAgent } from '@x/shared/src/code-mode.js'
import type { CodeAgentModelOptions, CodeAgentOption } from '@x/shared/src/code-sessions.js'
// Model + effort choices for a coding agent, discovered live from the engine
// (the same list `/model` shows) via the main process, which caches per agent.
// We memoize the in-flight/resolved promise per agent here too so reopening the
// picker doesn't re-hit IPC. A failed lookup resolves to empty lists so the UI
// just falls back to the engine default.
const EMPTY: CodeAgentModelOptions = { models: [], efforts: [] }
const cache = new Map<CodingAgent, Promise<CodeAgentModelOptions>>()
export function fetchCodeAgentOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
let pending = cache.get(agent)
if (!pending) {
pending = window.ipc.invoke('codeMode:listModelOptions', { agent }).catch(() => EMPTY)
cache.set(agent, pending)
}
return pending
}
// Always offer a Default fallback even before options load (or if discovery fails).
export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
}
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@ -31,6 +32,16 @@ const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height'
const TERMINAL_MIN_HEIGHT = 120
const TERMINAL_MAX_HEIGHT = 600
// Remember which session was open so leaving the Code section (which unmounts
// this view) and coming back restores the selection — and with it the chat
// output in the right pane — instead of dropping back to the empty state.
const SELECTED_SESSION_STORAGE_KEY = 'x:code-selected-session'
function readStoredSelectedSessionId(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(SELECTED_SESSION_STORAGE_KEY) || null
}
function readStoredTerminalHeight(): number {
if (typeof window === 'undefined') return 240
const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY))
@ -70,7 +81,7 @@ export function CodeView({
onDiffOpened?: () => void
}) {
const { projects, sessions, statusOf, refresh } = useCodeSessions()
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(readStoredSelectedSessionId)
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
const [terminalOpen, setTerminalOpen] = useState(false)
@ -81,6 +92,11 @@ export function CodeView({
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight))
}, [terminalHeight])
useEffect(() => {
if (selectedSessionId) window.localStorage.setItem(SELECTED_SESSION_STORAGE_KEY, selectedSessionId)
else window.localStorage.removeItem(SELECTED_SESSION_STORAGE_KEY)
}, [selectedSessionId])
const handleTerminalDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight }
@ -104,6 +120,17 @@ export function CodeView({
const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle'
const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null
// Live model/effort choices for the selected session's agent, for the header
// pickers. Discovered from the engine and cached, so this is cheap to re-run.
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
const selectedAgent = selectedSession?.agent
useEffect(() => {
if (!selectedAgent) { setModelOpts({ models: [], efforts: [] }); return }
let cancelled = false
void fetchCodeAgentOptions(selectedAgent).then((opts) => { if (!cancelled) setModelOpts(opts) })
return () => { cancelled = true }
}, [selectedAgent])
// Tell App which session (and status) owns the right-hand chat pane.
useEffect(() => {
onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null)
@ -152,7 +179,7 @@ export function CodeView({
}
}, [refresh, selectedSessionId])
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex' }) => {
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex'; agentModel?: string; agentEffort?: string }) => {
if (!selectedSessionId) return
try {
await window.ipc.invoke('codeSession:update', { sessionId: selectedSessionId, patch })
@ -201,6 +228,50 @@ export function CodeView({
</div>
</div>
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title="Coding agent model"
>
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
{withDefault(modelOpts.models).map((m) => (
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
{m.label}
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{modelOpts.efforts.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title="Reasoning effort"
>
<span className="whitespace-nowrap">{optionLabel(modelOpts.efforts, selectedSession.agentEffort)}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{withDefault(modelOpts.efforts).map((e) => (
<DropdownMenuItem key={e.value} onClick={() => void handleUpdateSession({ agentEffort: e.value })}>
{e.label}
{(selectedSession.agentEffort ?? 'default') === e.value && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
import type { CodeSession, CodeSessionMode } from '@x/shared/src/code-sessions.js'
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@ -86,6 +87,11 @@ export function NewSessionDialog({
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
// 'default' = let the backend use the configured default model.
const [modelKey, setModelKey] = useState('default')
// The coding agent's own model + reasoning effort. 'default' leaves the
// engine default. Choices are discovered live per agent (see effect below).
const [agentModel, setAgentModel] = useState('default')
const [agentEffort, setAgentEffort] = useState('default')
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
const git = projectRow?.git
const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits
@ -97,6 +103,8 @@ export function NewSessionDialog({
setIsolation('in-repo')
setMode('rowboat')
setModelKey('default')
setAgentModel('default')
setAgentEffort('default')
void loadModelOptions().then(setModelOptions)
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
setAgentStatus(status)
@ -108,6 +116,18 @@ export function NewSessionDialog({
})
}, [open])
// Model/effort choices are per-agent (and the saved value from one agent is
// meaningless for the other), so reset to defaults and (re)load the live list
// whenever the agent changes.
useEffect(() => {
setAgentModel('default')
setAgentEffort('default')
setModelOpts({ models: [], efforts: [] })
let cancelled = false
void fetchCodeAgentOptions(agent).then((opts) => { if (!cancelled) setModelOpts(opts) })
return () => { cancelled = true }
}, [agent])
const agentReady = (a: CodingAgent): boolean => {
if (!agentStatus) return true
const s = agentStatus[a]
@ -129,6 +149,8 @@ export function NewSessionDialog({
policy,
isolation,
...(picked ? { model: picked.model, provider: picked.provider } : {}),
...(agentModel !== 'default' ? { agentModel } : {}),
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
})
onOpenChange(false)
onCreated(res.session)
@ -278,6 +300,41 @@ export function NewSessionDialog({
</p>
</div>
{/* The coding agent's own model + reasoning effort, discovered live
from the engine and applied to the ACP session each turn (so they
stay editable from the session header later). Effort is a separate
axis only for Claude; Codex folds it into the model id. */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Model</label>
<Select value={agentModel} onValueChange={setAgentModel}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{withDefault(modelOpts.models).map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{modelOpts.efforts.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Effort</label>
<Select value={agentEffort} onValueChange={setAgentEffort}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{withDefault(modelOpts.efforts).map((e) => (
<SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* The model only powers Rowboat's own turns; the coding agent uses its
own configured model, so hide this entirely for direct sessions. */}
{mode === 'rowboat' && modelOptions.length > 0 && (

View file

@ -37,6 +37,74 @@ const STARTUP_TIMEOUT_MS = Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS) >
? Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS)
: 60_000;
export interface CodeAgentOption { value: string; label: string }
export interface CodeAgentModelOptions { models: CodeAgentOption[]; efforts: CodeAgentOption[] }
// The agent advertises its model + effort choices on the session it opens (the
// same data that backs its `/model` picker), in one of two shapes:
// - `configOptions`: select options with id "model" / "effort" (Claude).
// - `models`: a SessionModelState { availableModels: [{ modelId, name }] }
// (Codex — which folds effort into the model id, so no separate effort).
// We read configOptions first and fall back to `models`, then prepend a
// synthetic "Default" so the user can always keep the engine default.
type RawSelectOption = { value?: unknown; name?: unknown; options?: Array<{ value?: unknown; name?: unknown }> };
type RawConfigOption = { id?: string; options?: RawSelectOption[] };
type RawModelState = { availableModels?: Array<{ modelId?: unknown; name?: unknown }> };
function withDefault(choices: CodeAgentOption[]): CodeAgentOption[] {
return choices.some((c) => c.value === 'default')
? choices
: [{ value: 'default', label: 'Default' }, ...choices];
}
function toChoices(option: RawConfigOption | undefined): CodeAgentOption[] {
const flat = (option?.options ?? []).flatMap((o) => (Array.isArray(o.options) ? o.options : [o]));
return flat
.filter((o): o is { value: string; name?: unknown } => typeof o.value === 'string')
.map((o) => ({ value: o.value, label: typeof o.name === 'string' && o.name ? o.name : o.value }));
}
function modelStateChoices(models: RawModelState | undefined): CodeAgentOption[] {
return (models?.availableModels ?? [])
.filter((m): m is { modelId: string; name?: unknown } => typeof m.modelId === 'string')
.map((m) => ({ value: m.modelId, label: typeof m.name === 'string' && m.name ? m.name : m.modelId }));
}
export function extractModelOptions(configOptions: unknown, models?: unknown): CodeAgentModelOptions {
const list = (Array.isArray(configOptions) ? configOptions : []) as RawConfigOption[];
const modelOpt = list.find((o) => o.id === 'model');
const effortOpt = list.find((o) => o.id === 'effort');
const modelChoices = toChoices(modelOpt);
return {
// configOptions is authoritative when present; otherwise fall back to the
// SessionModelState list (Codex reports models only there).
models: withDefault(modelChoices.length ? modelChoices : modelStateChoices(models as RawModelState)),
efforts: effortOpt ? withDefault(toChoices(effortOpt)) : [],
};
}
// Claude's `availableModels` exposes its top model only as "Default
// (recommended)" and omits an explicit "Opus" row (the interactive `/model`
// lists it, the ACP adapter dedupes it). Surface the canonical aliases
// explicitly for clarity — the adapter resolves "opus"/"sonnet"/"haiku" to the
// concrete model. Deduped against what the engine already returned, so in
// practice this only adds the missing "Opus" entry, placed right after Default.
const CLAUDE_ALIAS_ROWS: CodeAgentOption[] = [
{ value: 'opus', label: 'Opus' },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'haiku', label: 'Haiku' },
];
function withClaudeAliases(options: CodeAgentModelOptions): CodeAgentModelOptions {
const have = new Set(options.models.map((m) => m.value));
const extra = CLAUDE_ALIAS_ROWS.filter((r) => !have.has(r.value));
if (extra.length === 0) return options;
const at = options.models.findIndex((m) => m.value === 'default');
const models = [...options.models];
models.splice(at >= 0 ? at + 1 : 0, 0, ...extra);
return { ...options, models };
}
// Map a raw ACP session/update notification onto our small CodeRunEvent union.
function toEvent(update: SessionUpdate): CodeRunEvent {
switch (update.sessionUpdate) {
@ -180,6 +248,20 @@ export class AcpClient {
}
}
// Open a throwaway session purely to read the agent's advertised model +
// effort choices, then let the caller dispose this client. Used for the
// model picker before any real session exists.
async describeModelOptions(): Promise<CodeAgentModelOptions> {
try {
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
const r = res as { configOptions?: unknown; models?: unknown };
const options = extractModelOptions(r.configOptions, r.models);
return this.agent === 'claude' ? withClaudeAliases(options) : options;
} catch (e) {
throw this.enrich(e, 'describeModelOptions');
}
}
async loadSession(sessionId: string): Promise<void> {
try {
await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }));
@ -188,6 +270,20 @@ export class AcpClient {
}
}
// Point the open session at a specific model. The adapter resolves aliases
// ("opus"/"sonnet"/…) to concrete ids. Throws if the model is unknown; the
// caller applies this best-effort so a bad value never blocks a turn.
async setModel(sessionId: string, modelId: string): Promise<void> {
await this.conn().unstable_setSessionModel({ sessionId, modelId });
}
// Set the reasoning-effort level via the agent's "effort" config option.
// The option only exists for models that support it, so this throws for
// others — again applied best-effort by the caller.
async setEffort(sessionId: string, value: string): Promise<void> {
await this.conn().setSessionConfigOption({ sessionId, configId: 'effort', value });
}
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
try {
return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] });

View file

@ -1,5 +1,6 @@
import * as os from 'os';
import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js';
import { AcpClient } from './client.js';
import { AcpClient, type CodeAgentModelOptions } from './client.js';
import { PermissionBroker } from './permission-broker.js';
import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js';
@ -9,6 +10,11 @@ export interface RunPromptArgs {
cwd: string;
prompt: string;
policy: ApprovalPolicy;
/** Coding-agent model alias/id (e.g. "opus"); applied to the ACP session
* before the prompt. Omitted / "default" leaves the engine default. */
model?: string;
/** Reasoning-effort level (e.g. "high"); applied alongside the model. */
effort?: string;
/** Called when the policy needs the user to decide (the "ask" path). */
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
/** Stream sink for this prompt's run. */
@ -56,9 +62,32 @@ const CANCEL_GRACE_MS = 2_000;
// resumes the persisted session via session/load.
export class CodeModeManager {
private readonly runs = new Map<string, ActiveRun>();
// Per-agent model/effort choices, discovered once from the engine and reused
// (the list only changes when the provider ships new models, and the app can
// be restarted to pick those up). Avoids cold-starting an adapter per picker.
private readonly modelOptionsCache = new Map<CodingAgent, CodeAgentModelOptions>();
// Discover a coding agent's available models + effort levels straight from
// the engine (what its `/model` picker would show). Spawns a short-lived
// adapter, opens a throwaway session to read its advertised options, and
// tears it down. Cached per agent for the lifetime of the process.
async listModelOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
const cached = this.modelOptionsCache.get(agent);
if (cached) return cached;
const broker = new PermissionBroker({ policy: 'yolo', ask: async () => 'reject' });
const client = new AcpClient({ agent, cwd: os.homedir(), broker, onEvent: () => {} });
try {
await client.start();
const options = await client.describeModelOptions();
this.modelOptionsCache.set(agent, options);
return options;
} finally {
client.dispose();
}
}
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal, suppressReplay } = args;
const { runId, agent, cwd, prompt, policy, model, effort, ask, onEvent, signal, suppressReplay } = args;
const broker = new PermissionBroker({
policy,
@ -67,6 +96,10 @@ export class CodeModeManager {
});
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent, suppressReplay ?? false);
// Re-apply the session's model + effort each turn (idempotent): a warm
// connection keeps the last selection, but a cold session/load resets it,
// and the user may have changed it from the header since the last turn.
await this.applyModelAndEffort(run, model, effort);
run.inflight++;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
@ -109,6 +142,26 @@ export class CodeModeManager {
}
}
// Best-effort: a model the engine doesn't know, or an effort level a model
// doesn't support, must not abort the turn — we log and proceed with the
// engine default rather than surfacing a hard error to the user.
private async applyModelAndEffort(run: ActiveRun, model?: string, effort?: string): Promise<void> {
if (model && model !== 'default') {
try {
await run.client.setModel(run.sessionId, model);
} catch (e) {
console.warn(`[code-mode] could not set model "${model}": ${e instanceof Error ? e.message : String(e)}`);
}
}
if (effort && effort !== 'default') {
try {
await run.client.setEffort(run.sessionId, effort);
} catch (e) {
console.warn(`[code-mode] could not set effort "${effort}": ${e instanceof Error ? e.message : String(e)}`);
}
}
}
dispose(runId: string): void {
const run = this.runs.get(runId);
if (!run) return;

View file

@ -27,6 +27,10 @@ export interface CreateSessionArgs {
// LLM for Rowboat-mode turns; unset falls through to the configured default.
model?: string;
provider?: string;
// The coding agent's own model + reasoning effort (ACP engine); unset leaves
// the engine default. Re-applied to the ACP session on every turn.
agentModel?: string;
agentEffort?: string;
}
export interface SendMessageResult {
@ -142,6 +146,8 @@ export class CodeSessionService {
policy: args.policy,
cwd,
...(worktree ? { worktree } : {}),
...(args.agentModel ? { agentModel: args.agentModel } : {}),
...(args.agentEffort ? { agentEffort: args.agentEffort } : {}),
createdAt: new Date().toISOString(),
};
await this.codeSessionsRepo.save(session);
@ -149,7 +155,7 @@ export class CodeSessionService {
return session;
}
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent'>>): Promise<CodeSession> {
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent' | 'agentModel' | 'agentEffort'>>): Promise<CodeSession> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session) throw new Error(`Unknown session: ${sessionId}`);
const updated: CodeSession = { ...session, ...patch };
@ -217,6 +223,8 @@ export class CodeSessionService {
cwd: session.cwd,
prompt: text,
policy: session.policy,
...(session.agentModel ? { model: session.agentModel } : {}),
...(session.agentEffort ? { effort: session.agentEffort } : {}),
signal,
suppressReplay: true,
onEvent: (event) => {

View file

@ -53,11 +53,34 @@ export const CodeSession = z.object({
// Where the agent works: the project path, or the worktree path.
cwd: z.string(),
worktree: CodeWorktree.optional(),
// The coding agent's own model + reasoning effort (applied to the ACP engine,
// not the Rowboat-mode LLM). Values come from CODE_AGENT_MODELS /
// CODE_AGENT_EFFORTS; unset (or 'default') leaves the engine's own default.
agentModel: z.string().optional(),
agentEffort: z.string().optional(),
createdAt: z.iso.datetime(),
lastActivityAt: z.iso.datetime().optional(),
});
export type CodeSession = z.infer<typeof CodeSession>;
// Model + effort choices for the ACP coding agents are discovered live from the
// engine (the same list `/model` shows), not hardcoded — so they always reflect
// whatever the provider currently offers. See the `codeMode:listModelOptions`
// IPC and CodeModeManager.listModelOptions. 'default' is a synthetic sentinel
// meaning "don't override the engine default".
//
// Claude exposes model and effort as two independent options; Codex folds the
// reasoning effort into the model id ("gpt-5-codex[high]") and so reports no
// separate effort list. The UI renders whatever each agent advertises.
export const CodeAgentOption = z.object({ value: z.string(), label: z.string() });
export type CodeAgentOption = z.infer<typeof CodeAgentOption>;
export const CodeAgentModelOptions = z.object({
models: z.array(CodeAgentOption),
efforts: z.array(CodeAgentOption),
});
export type CodeAgentModelOptions = z.infer<typeof CodeAgentModelOptions>;
export const GitFileState = z.enum(["modified", "added", "deleted", "untracked", "renamed"]);
export type GitFileState = z.infer<typeof GitFileState>;

View file

@ -21,7 +21,7 @@ import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js';
import { NotificationSettingsSchema } from './notification-settings.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile } from './code-sessions.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -601,6 +601,10 @@ const ipcSchemas = {
// chat, the model is fixed once the session's run exists.
model: z.string().optional(),
provider: z.string().optional(),
// The coding agent's own model + reasoning effort (ACP engine). Unlike the
// Rowboat model these are re-applied each turn, so they stay editable.
agentModel: z.string().optional(),
agentEffort: z.string().optional(),
}),
res: z.object({
session: CodeSession,
@ -616,12 +620,18 @@ const ipcSchemas = {
'codeSession:update': {
req: z.object({
sessionId: z.string(),
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true }).partial(),
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true, agentModel: true, agentEffort: true }).partial(),
}),
res: z.object({
session: CodeSession,
}),
},
// Live model + effort choices for a coding agent, discovered from the engine
// (cached per agent in the main process). Mirrors what `/model` would show.
'codeMode:listModelOptions': {
req: z.object({ agent: CodingAgent }),
res: CodeAgentModelOptions,
},
'codeSession:delete': {
req: z.object({
sessionId: z.string(),