Skip to content

Commit 7e484b9

Browse files
committed
feat(acp): pre-warm session on connect and expose reasoning efforts
1 parent bdef6ec commit 7e484b9

File tree

8 files changed

+202
-6
lines changed

8 files changed

+202
-6
lines changed

src-tauri/src/backend/app_server.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ pub(crate) struct WorkspaceSession {
6262
pub(crate) translation_state: Mutex<SessionTranslationState>,
6363
/// Cached ACP model payload from `session/new`/`session/load`.
6464
pub(crate) models_cache: Mutex<Option<Value>>,
65+
/// Pre-warmed ACP session ID created eagerly on workspace connect.
66+
/// Consumed by the first `start_thread` call to avoid a duplicate `session/new`.
67+
pub(crate) prewarmed_session_id: Mutex<Option<String>>,
6568
/// One in-flight `session/prompt` at a time per workspace session.
6669
pub(crate) prompt_lock: Mutex<()>,
6770
/// Capability state for best-effort model switching.
@@ -387,6 +390,7 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
387390
background_thread_callbacks: Mutex::new(HashMap::new()),
388391
translation_state: Mutex::new(SessionTranslationState::new(String::new())),
389392
models_cache: Mutex::new(None),
393+
prewarmed_session_id: Mutex::new(None),
390394
prompt_lock: Mutex::new(()),
391395
model_set_capability: Mutex::new(ModelSetCapability::Unknown),
392396
model_set_warning_emitted: Mutex::new(false),
@@ -564,6 +568,59 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
564568
};
565569
event_sink.emit_app_server_event(payload);
566570

571+
// Eagerly create a session to pre-populate the models cache so the
572+
// frontend model selector is populated before the user sends a prompt.
573+
let prewarm_session = Arc::clone(&session);
574+
let prewarm_sink = event_sink.clone();
575+
let prewarm_workspace_id = entry.id.clone();
576+
let prewarm_cwd = entry.path.clone();
577+
tokio::spawn(async move {
578+
let params = json!({
579+
"cwd": prewarm_cwd,
580+
"mcpServers": []
581+
});
582+
match prewarm_session.send_request("session/new", params).await {
583+
Ok(response) => {
584+
let session_id = response
585+
.get("result")
586+
.and_then(|r| r.get("sessionId"))
587+
.or_else(|| response.get("sessionId"))
588+
.and_then(|v| v.as_str())
589+
.unwrap_or_default()
590+
.to_string();
591+
592+
if let Some(models) = response
593+
.get("result")
594+
.unwrap_or(&response)
595+
.get("models")
596+
.cloned()
597+
{
598+
*prewarm_session.models_cache.lock().await = Some(models);
599+
}
600+
601+
if !session_id.is_empty() {
602+
*prewarm_session.prewarmed_session_id.lock().await =
603+
Some(session_id);
604+
}
605+
606+
let payload = AppServerEvent {
607+
workspace_id: prewarm_workspace_id.clone(),
608+
message: json!({
609+
"method": "codex/modelsReady",
610+
"params": { "workspaceId": prewarm_workspace_id }
611+
}),
612+
};
613+
prewarm_sink.emit_app_server_event(payload);
614+
}
615+
Err(err) => {
616+
eprintln!(
617+
"Pre-warm session/new failed for {}: {}",
618+
prewarm_workspace_id, err
619+
);
620+
}
621+
}
622+
});
623+
567624
Ok(session)
568625
}
569626

src-tauri/src/shared/codex_core.rs

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,24 @@ pub(crate) async fn start_thread_core(
110110
workspace_id: String,
111111
) -> Result<Value, String> {
112112
let session = get_session_clone(sessions, &workspace_id).await?;
113+
114+
// Reuse the pre-warmed session if available (created eagerly on workspace
115+
// connect to populate the models cache ahead of the first prompt).
116+
if let Some(session_id) = session.prewarmed_session_id.lock().await.take() {
117+
let mut ts = session.translation_state.lock().await;
118+
ts.session_id = session_id.clone();
119+
return Ok(json!({
120+
"result": {
121+
"thread": { "id": session_id }
122+
}
123+
}));
124+
}
125+
113126
let params = json!({
114127
"cwd": session.entry.path,
115128
"mcpServers": []
116129
});
117130
let response = session.send_request("session/new", params).await?;
118-
// ACP returns { sessionId, models, modes, ... }.
119-
// Frontend expects { result: { thread: { id: "..." } } }.
120131
let session_id = response
121132
.get("result")
122133
.and_then(|r| r.get("sessionId"))
@@ -1045,6 +1056,34 @@ pub(crate) async fn start_review_core(
10451056
Err("review is not supported by OpenCode ACP".to_string())
10461057
}
10471058

1059+
const THINKING_LEVELS: &[&str] = &["low", "medium", "high", "max"];
1060+
1061+
/// Returns `Some((base_name, level))` when `name` ends with ` (<known_level>)`.
1062+
fn strip_thinking_suffix(name: &str) -> Option<(&str, &str)> {
1063+
let name = name.trim();
1064+
let rest = name.strip_suffix(')')?;
1065+
let paren_start = rest.rfind(" (")?;
1066+
let level = &rest[paren_start + 2..];
1067+
if THINKING_LEVELS
1068+
.iter()
1069+
.any(|tl| tl.eq_ignore_ascii_case(level))
1070+
{
1071+
Some((name[..paren_start].trim(), level))
1072+
} else {
1073+
None
1074+
}
1075+
}
1076+
1077+
fn thinking_level_order(level: &str) -> usize {
1078+
match level.to_ascii_lowercase().as_str() {
1079+
"low" => 0,
1080+
"medium" => 1,
1081+
"high" => 2,
1082+
"max" => 3,
1083+
_ => 99,
1084+
}
1085+
}
1086+
10481087
pub(crate) async fn model_list_core(
10491088
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
10501089
workspace_id: String,
@@ -1065,6 +1104,31 @@ pub(crate) async fn model_list_core(
10651104
.cloned()
10661105
.unwrap_or_default();
10671106

1107+
// Pass 1 — collect thinking-variant effort levels keyed by base display
1108+
// name, and record which modelIds are variants so they can be skipped.
1109+
let mut variant_levels: HashMap<String, Vec<String>> = HashMap::new();
1110+
let mut variant_model_ids: HashSet<String> = HashSet::new();
1111+
1112+
for entry in &available_models {
1113+
let display_name = entry
1114+
.get("name")
1115+
.and_then(|v| v.as_str())
1116+
.unwrap_or_default();
1117+
if let Some((base_name, level)) = strip_thinking_suffix(display_name) {
1118+
variant_levels
1119+
.entry(base_name.to_string())
1120+
.or_default()
1121+
.push(level.to_string());
1122+
if let Some(mid) = entry.get("modelId").and_then(|v| v.as_str()) {
1123+
variant_model_ids.insert(mid.trim().to_string());
1124+
}
1125+
}
1126+
}
1127+
for levels in variant_levels.values_mut() {
1128+
levels.sort_by_key(|l| thinking_level_order(l));
1129+
}
1130+
1131+
// Pass 2 — emit base models only, with effort levels attached.
10681132
let data: Vec<Value> = available_models
10691133
.into_iter()
10701134
.filter_map(|entry| {
@@ -1074,7 +1138,7 @@ pub(crate) async fn model_list_core(
10741138
.unwrap_or_default()
10751139
.trim()
10761140
.to_string();
1077-
if model_id.is_empty() {
1141+
if model_id.is_empty() || variant_model_ids.contains(&model_id) {
10781142
return None;
10791143
}
10801144
let display_name = entry
@@ -1084,13 +1148,47 @@ pub(crate) async fn model_list_core(
10841148
.trim()
10851149
.to_string();
10861150

1151+
// Prefer explicit ACP field; fall back to levels scraped from
1152+
// variant entries whose base name matches this model.
1153+
let acp_efforts = entry
1154+
.get("supportedReasoningEfforts")
1155+
.and_then(|v| v.as_array())
1156+
.filter(|a| !a.is_empty());
1157+
1158+
let efforts: Vec<Value> = if let Some(acp) = acp_efforts {
1159+
acp.iter()
1160+
.map(|e| {
1161+
if e.is_object() {
1162+
e.clone()
1163+
} else {
1164+
let label = e.as_str().unwrap_or_default();
1165+
json!({ "reasoningEffort": label, "description": "" })
1166+
}
1167+
})
1168+
.collect()
1169+
} else {
1170+
variant_levels
1171+
.get(&display_name)
1172+
.into_iter()
1173+
.flatten()
1174+
.map(|level| json!({ "reasoningEffort": level, "description": "" }))
1175+
.collect()
1176+
};
1177+
1178+
let default_effort = entry
1179+
.get("defaultReasoningEffort")
1180+
.and_then(|v| v.as_str())
1181+
.filter(|s| !s.trim().is_empty())
1182+
.map(|s| Value::String(s.trim().to_string()))
1183+
.unwrap_or(Value::Null);
1184+
10871185
Some(json!({
10881186
"id": model_id.clone(),
10891187
"model": model_id.clone(),
10901188
"displayName": display_name,
10911189
"description": "",
1092-
"supportedReasoningEfforts": [],
1093-
"defaultReasoningEffort": null,
1190+
"supportedReasoningEfforts": efforts,
1191+
"defaultReasoningEffort": default_effort,
10941192
"isDefault": model_id == current_model,
10951193
}))
10961194
})

src/features/app/hooks/useAppServerEvents.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type AgentCompleted = {
3131
type AppServerEventHandlers = {
3232
onWorkspaceConnected?: (workspaceId: string) => void;
3333
onWorkspaceDisconnected?: (workspaceId: string) => void;
34+
onModelsReady?: (workspaceId: string) => void;
3435
onThreadStarted?: (workspaceId: string, thread: Record<string, unknown>) => void;
3536
onThreadNameUpdated?: (
3637
workspaceId: string,
@@ -98,6 +99,7 @@ export const METHODS_ROUTED_IN_USE_APP_SERVER_EVENTS = [
9899
"codex/backgroundThread",
99100
"codex/connected",
100101
"codex/disconnected",
102+
"codex/modelsReady",
101103
"error",
102104
"item/agentMessage/delta",
103105
"item/commandExecution/outputDelta",
@@ -150,6 +152,11 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) {
150152
return;
151153
}
152154

155+
if (method === "codex/modelsReady") {
156+
currentHandlers.onModelsReady?.(workspace_id);
157+
return;
158+
}
159+
153160
const requestId = getAppServerRequestId(payload);
154161
const hasRequestId = requestId !== null;
155162

src/features/composer/components/Composer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type ComposerProps = {
5050
onStop: () => void;
5151
canStop: boolean;
5252
disabled?: boolean;
53+
isConnected?: boolean;
5354
appsEnabled: boolean;
5455
isProcessing: boolean;
5556
steerEnabled: boolean;
@@ -153,6 +154,7 @@ export const Composer = memo(function Composer({
153154
onStop,
154155
canStop,
155156
disabled = false,
157+
isConnected = false,
156158
appsEnabled,
157159
isProcessing,
158160
steerEnabled,
@@ -815,6 +817,7 @@ export const Composer = memo(function Composer({
815817
/>
816818
<ComposerMetaBar
817819
disabled={disabled}
820+
isConnected={isConnected}
818821
collaborationModes={collaborationModes}
819822
selectedCollaborationModeId={selectedCollaborationModeId}
820823
onSelectCollaborationMode={onSelectCollaborationMode}

src/features/composer/components/ComposerMetaBar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AccessMode, ThreadTokenUsage } from "../../../types";
44

55
type ComposerMetaBarProps = {
66
disabled: boolean;
7+
isConnected?: boolean;
78
collaborationModes: { id: string; label: string }[];
89
selectedCollaborationModeId: string | null;
910
onSelectCollaborationMode: (id: string | null) => void;
@@ -21,6 +22,7 @@ type ComposerMetaBarProps = {
2122

2223
export function ComposerMetaBar({
2324
disabled,
25+
isConnected = false,
2426
collaborationModes,
2527
selectedCollaborationModeId,
2628
onSelectCollaborationMode,
@@ -167,7 +169,9 @@ export function ComposerMetaBar({
167169
onChange={(event) => onSelectModel(event.target.value)}
168170
disabled={disabled}
169171
>
170-
{models.length === 0 && <option value="">No models</option>}
172+
{models.length === 0 && (
173+
<option value="">{isConnected ? "Loading models..." : "No models"}</option>
174+
)}
171175
{models.map((model) => (
172176
<option key={model.id} value={model.id}>
173177
{model.displayName || model.model}

src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod
123123
onStop={options.onStop}
124124
canStop={options.canStop}
125125
disabled={options.isReviewing}
126+
isConnected={options.activeWorkspace?.connected ?? false}
126127
onFileAutocompleteActiveChange={options.onFileAutocompleteActiveChange}
127128
contextUsage={options.activeTokenUsage}
128129
queuedMessages={options.activeQueue}

src/features/models/hooks/useModels.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import type { DebugEntry, ModelOption, WorkspaceInfo } from "../../../types";
33
import { getConfigModel, getModelList } from "../../../services/tauri";
4+
import { subscribeAppServerEvents } from "../../../services/events";
5+
import { getAppServerRawMethod } from "../../../utils/appServerEvents";
46
import {
57
normalizeEffortValue,
68
parseModelListResponse,
@@ -287,6 +289,29 @@ export function useModels({
287289
refreshModels();
288290
}, [isConnected, models.length, refreshModels, selectionKey, workspaceId]);
289291

292+
useEffect(() => {
293+
if (!workspaceId || !isConnected) {
294+
return;
295+
}
296+
const unsub = subscribeAppServerEvents((event) => {
297+
const method = getAppServerRawMethod(event);
298+
if (method !== "codex/modelsReady") {
299+
return;
300+
}
301+
const wsId =
302+
event &&
303+
typeof event === "object" &&
304+
"workspace_id" in event &&
305+
typeof (event as Record<string, unknown>).workspace_id === "string"
306+
? (event as Record<string, unknown>).workspace_id
307+
: null;
308+
if (wsId === workspaceId) {
309+
refreshModels();
310+
}
311+
});
312+
return unsub;
313+
}, [isConnected, refreshModels, workspaceId]);
314+
290315
useEffect(() => {
291316
if (!selectedModel) {
292317
return;

src/utils/appServerEvents.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const SUPPORTED_APP_SERVER_METHODS = [
88
"codex/backgroundThread",
99
"codex/connected",
1010
"codex/disconnected",
11+
"codex/modelsReady",
1112
"codex/event/skills_update_available",
1213
"error",
1314
"item/agentMessage/delta",

0 commit comments

Comments
 (0)