From 96122c806e1b916bbc2e60aa396dfffc950bf820 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Tue, 1 Sep 2026 23:36:22 +0530 Subject: [PATCH 1/2] fix(cursor): resolve model from model_id and transcript fallbacks Read Cursor's model_id when model is missing, extract model from agent transcripts when hooks only send Auto/default placeholders, and hide tool-scoped unknown-model labels in the VS Code hover. Co-authored-by: Cursor --- .../vscode/src/blame-lens-manager.ts | 20 ++- .../checkpoint_agent/presets/cursor.rs | 118 ++++++++++++++++-- src/streams/model_extraction.rs | 23 +++- 3 files changed, 148 insertions(+), 13 deletions(-) diff --git a/agent-support/vscode/src/blame-lens-manager.ts b/agent-support/vscode/src/blame-lens-manager.ts index 8e1c25d..bb8acdc 100644 --- a/agent-support/vscode/src/blame-lens-manager.ts +++ b/agent-support/vscode/src/blame-lens-manager.ts @@ -1127,6 +1127,19 @@ export class BlameLensManager { * Extract model name from model string (e.g., "claude-3-opus-20240229" -> "Claude") * Returns the part before the first "-" with first letter capitalized, or null if no model. */ + private shouldHideModelName(model: string | undefined): boolean { + if (!model || model.trim() === '') { + return true; + } + const lower = model.trim().toLowerCase(); + return ( + lower === 'default' || + lower === 'auto' || + lower === 'unknown' || + lower.endsWith('/unknown-model') + ); + } + private extractModelName(modelString: string | undefined): string | null { if (!modelString || modelString.trim() === '') { return null; @@ -1138,7 +1151,7 @@ export class BlameLensManager { if (trimmed === 'default' || trimmed === 'auto') { return 'Cursor'; } - if (trimmed === 'unknown') { + if (trimmed === 'unknown' || trimmed.endsWith('/unknown-model')) { return null; // Will display as "AI" } @@ -1281,9 +1294,8 @@ export class BlameLensManager { const tool = record?.agent_id?.tool || lineInfo.author; const toolCapitalized = tool.charAt(0).toUpperCase() + tool.slice(1); - // Build model display: hide if default/auto/unknown/empty - const modelLower = model.toLowerCase(); - const hideModel = !model || modelLower === 'default' || modelLower === 'auto' || modelLower === 'unknown'; + // Build model display: hide placeholders and tool-scoped unknown fallbacks + const hideModel = this.shouldHideModelName(model); const modelDisplay = hideModel ? '' : model; // ═══════════════════════════════════════════════════════════════ diff --git a/src/commands/checkpoint_agent/presets/cursor.rs b/src/commands/checkpoint_agent/presets/cursor.rs index b8254ad..27ad13d 100644 --- a/src/commands/checkpoint_agent/presets/cursor.rs +++ b/src/commands/checkpoint_agent/presets/cursor.rs @@ -8,7 +8,7 @@ use crate::authorship::working_log::AgentId; use crate::commands::checkpoint_agent::bash_tool::{self, Agent, ToolClass}; use crate::error::AutterError; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub struct CursorPreset; @@ -46,10 +46,11 @@ impl AgentPreset for CursorPreset { let hook_event_name = parse::required_str(&data, "hook_event_name")?; - // Extract model from hook input (Cursor provides this directly) - let model = parse::optional_str(&data, "model") - .unwrap_or("unknown") - .to_string(); + let transcript_path = parse::optional_str(&data, "transcript_path").map(|s| s.to_string()); + + // Cursor documents both `model` and `model_id` on pre/postToolUse hooks. + // Fall back to the transcript when the hook only carries placeholders (Auto, etc.). + let model = resolve_cursor_model(&data, transcript_path.as_deref()); // Legacy hooks no longer installed; return error so orchestrator skips. if hook_event_name == "beforeSubmitPrompt" || hook_event_name == "afterFileEdit" { @@ -92,8 +93,6 @@ impl AgentPreset for CursorPreset { vec![] }; - let transcript_path = parse::optional_str(&data, "transcript_path").map(|s| s.to_string()); - let mut metadata = HashMap::new(); if let Some(ref tp) = transcript_path { metadata.insert("transcript_path".to_string(), tp.clone()); @@ -177,6 +176,38 @@ fn normalize_cursor_path(path: &str) -> String { path.to_string() } +fn is_cursor_placeholder_model(model: &str) -> bool { + let m = model.trim(); + m.is_empty() + || m.eq_ignore_ascii_case("unknown") + || m.eq_ignore_ascii_case("default") + || m.eq_ignore_ascii_case("auto") +} + +/// Resolve the model for a Cursor hook: prefer hook `model`/`model_id`, then transcript. +fn resolve_cursor_model(data: &serde_json::Value, transcript_path: Option<&str>) -> String { + let hook_model = parse::optional_str_multi(data, &["model", "model_id"]); + + if let Some(model) = hook_model { + if !is_cursor_placeholder_model(model) { + return model.to_string(); + } + } + + if let Some(path) = transcript_path { + if let Ok(Some(model)) = crate::streams::model_extraction::extract_model( + Path::new(path), + crate::streams::sweep::StreamFormat::CursorJsonl, + None, + ) && !is_cursor_placeholder_model(&model) + { + return model; + } + } + + hook_model.unwrap_or("unknown").to_string() +} + fn cursor_file_path_from_tool_input(tool_input: Option<&serde_json::Value>) -> String { tool_input .and_then(|ti| { @@ -512,6 +543,79 @@ mod tests { } } + #[test] + fn test_cursor_model_id_fallback_when_model_missing() { + let input = json!({ + "conversation_id": "conv-123", + "workspace_roots": ["/home/user/project"], + "hook_event_name": "preToolUse", + "tool_name": "Write", + "model_id": "claude-opus-4-7", + "tool_input": {"file_path": "src/main.rs"} + }) + .to_string(); + let events = CursorPreset.parse(&input, "t_test123456789a").unwrap(); + match &events[0] { + ParsedHookEvent::PreFileEdit(e) => { + assert_eq!(e.context.agent_id.model, "claude-opus-4-7"); + } + _ => panic!("Expected PreFileEdit"), + } + } + + #[test] + fn test_cursor_prefers_model_over_model_id() { + let input = json!({ + "conversation_id": "conv-123", + "workspace_roots": ["/home/user/project"], + "hook_event_name": "preToolUse", + "tool_name": "Write", + "model": "composer-2", + "model_id": "claude-opus-4-7", + "tool_input": {"file_path": "src/main.rs"} + }) + .to_string(); + let events = CursorPreset.parse(&input, "t_test123456789a").unwrap(); + match &events[0] { + ParsedHookEvent::PreFileEdit(e) => { + assert_eq!(e.context.agent_id.model, "composer-2"); + } + _ => panic!("Expected PreFileEdit"), + } + } + + #[test] + fn test_cursor_resolves_model_from_transcript_when_hook_is_auto() { + use std::io::Write; + use tempfile::NamedTempFile; + + let mut transcript = NamedTempFile::new().unwrap(); + writeln!( + transcript, + r#"{{"role":"assistant","message":{{"model":"claude-sonnet-4","content":[{{"type":"text","text":"hi"}}]}}}}"# + ) + .unwrap(); + transcript.flush().unwrap(); + + let input = json!({ + "conversation_id": "conv-123", + "workspace_roots": ["/home/user/project"], + "hook_event_name": "postToolUse", + "tool_name": "Write", + "model": "auto", + "transcript_path": transcript.path().to_string_lossy(), + "tool_input": {"file_path": "src/main.rs"} + }) + .to_string(); + let events = CursorPreset.parse(&input, "t_test123456789a").unwrap(); + match &events[0] { + ParsedHookEvent::PostFileEdit(e) => { + assert_eq!(e.context.agent_id.model, "claude-sonnet-4"); + } + _ => panic!("Expected PostFileEdit"), + } + } + #[test] fn test_matching_workspace_root() { let roots = vec![ diff --git a/src/streams/model_extraction.rs b/src/streams/model_extraction.rs index e3763c0..c28aa8e 100644 --- a/src/streams/model_extraction.rs +++ b/src/streams/model_extraction.rs @@ -12,7 +12,8 @@ pub fn extract_model( match format { StreamFormat::ClaudeJsonl | StreamFormat::CopilotEventStreamJsonl - | StreamFormat::GeminiJsonl => extract_model_from_jsonl_tail(path), + | StreamFormat::GeminiJsonl + | StreamFormat::CursorJsonl => extract_model_from_jsonl_tail(path), StreamFormat::CopilotSessionJson => extract_model_from_copilot_session_json(path), StreamFormat::AmpThreadJson => extract_model_from_amp_thread_json(path), StreamFormat::OpenCodeSqlite => extract_model_from_opencode_sqlite(path, session_id), @@ -103,7 +104,8 @@ fn extract_model_from_jsonl_line(line: &str) -> Option { .get("message") .and_then(|m| m.get("model")) .and_then(|v| v.as_str()) - .or_else(|| json.get("model").and_then(|v| v.as_str())); + .or_else(|| json.get("model").and_then(|v| v.as_str())) + .or_else(|| json.get("model_id").and_then(|v| v.as_str())); if let Some(model) = candidate && model != "" @@ -369,6 +371,23 @@ mod tests { assert_eq!(result, None); } + #[test] + fn test_extract_model_cursor_jsonl() { + use std::io::Write; + use tempfile::NamedTempFile; + + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{"role":"assistant","message":{{"model":"composer-2","content":[{{"type":"text","text":"ok"}}]}}}}"# + ) + .unwrap(); + file.flush().unwrap(); + + let result = extract_model(file.path(), StreamFormat::CursorJsonl, None).unwrap(); + assert_eq!(result, Some("composer-2".to_string())); + } + #[test] fn test_extract_model_missing_file() { let path = PathBuf::from("/nonexistent/path/to/file.jsonl"); From cb4d056d2f220cfa058ddcf09a025324398b34be Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Wed, 2 Sep 2026 00:43:15 +0530 Subject: [PATCH 2/2] fix(cursor): resolve model_id when hook model is a placeholder Read model and model_id independently so preToolUse hooks with model: auto and a concrete model_id keep the real model when no transcript is available. Return unknown instead of persisting placeholder values when no usable model source is found. Co-authored-by: Cursor --- .../checkpoint_agent/presets/cursor.rs | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/src/commands/checkpoint_agent/presets/cursor.rs b/src/commands/checkpoint_agent/presets/cursor.rs index 27ad13d..dbaa428 100644 --- a/src/commands/checkpoint_agent/presets/cursor.rs +++ b/src/commands/checkpoint_agent/presets/cursor.rs @@ -184,9 +184,10 @@ fn is_cursor_placeholder_model(model: &str) -> bool { || m.eq_ignore_ascii_case("auto") } -/// Resolve the model for a Cursor hook: prefer hook `model`/`model_id`, then transcript. +/// Resolve the model for a Cursor hook: prefer hook `model`, then `model_id`, then transcript. fn resolve_cursor_model(data: &serde_json::Value, transcript_path: Option<&str>) -> String { - let hook_model = parse::optional_str_multi(data, &["model", "model_id"]); + let hook_model = parse::optional_str(data, "model"); + let hook_model_id = parse::optional_str(data, "model_id"); if let Some(model) = hook_model { if !is_cursor_placeholder_model(model) { @@ -194,6 +195,12 @@ fn resolve_cursor_model(data: &serde_json::Value, transcript_path: Option<&str>) } } + if let Some(model_id) = hook_model_id { + if !is_cursor_placeholder_model(model_id) { + return model_id.to_string(); + } + } + if let Some(path) = transcript_path { if let Ok(Some(model)) = crate::streams::model_extraction::extract_model( Path::new(path), @@ -205,7 +212,7 @@ fn resolve_cursor_model(data: &serde_json::Value, transcript_path: Option<&str>) } } - hook_model.unwrap_or("unknown").to_string() + "unknown".to_string() } fn cursor_file_path_from_tool_input(tool_input: Option<&serde_json::Value>) -> String { @@ -584,6 +591,47 @@ mod tests { } } + #[test] + fn test_cursor_model_id_fallback_when_model_is_placeholder() { + let input = json!({ + "conversation_id": "conv-123", + "workspace_roots": ["/home/user/project"], + "hook_event_name": "preToolUse", + "tool_name": "Write", + "model": "auto", + "model_id": "claude-opus-4-7", + "tool_input": {"file_path": "src/main.rs"} + }) + .to_string(); + let events = CursorPreset.parse(&input, "t_test123456789a").unwrap(); + match &events[0] { + ParsedHookEvent::PreFileEdit(e) => { + assert_eq!(e.context.agent_id.model, "claude-opus-4-7"); + } + _ => panic!("Expected PreFileEdit"), + } + } + + #[test] + fn test_cursor_unknown_when_only_placeholder_model_and_no_transcript() { + let input = json!({ + "conversation_id": "conv-123", + "workspace_roots": ["/home/user/project"], + "hook_event_name": "preToolUse", + "tool_name": "Write", + "model": "auto", + "tool_input": {"file_path": "src/main.rs"} + }) + .to_string(); + let events = CursorPreset.parse(&input, "t_test123456789a").unwrap(); + match &events[0] { + ParsedHookEvent::PreFileEdit(e) => { + assert_eq!(e.context.agent_id.model, "unknown"); + } + _ => panic!("Expected PreFileEdit"), + } + } + #[test] fn test_cursor_resolves_model_from_transcript_when_hook_is_auto() { use std::io::Write;