mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
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:
parent
c8d801a123
commit
45188e7c1c
9 changed files with 360 additions and 9 deletions
|
|
@ -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 }] });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue