-
Notifications
You must be signed in to change notification settings - Fork 1
fix(cursor): resolve model from model_id and transcript fallbacks #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1127,6 +1127,19 @@ | |
| * 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 @@ | |
| 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 @@ | |
| 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 | ||
|
Check warning on line 1297 in agent-support/vscode/src/blame-lens-manager.ts
|
||
| const hideModel = this.shouldHideModelName(model); | ||
| const modelDisplay = hideModel ? '' : model; | ||
|
|
||
| // ═══════════════════════════════════════════════════════════════ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ | |
| 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 @@ | |
|
|
||
| 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()); | ||
|
Check warning on line 49 in src/commands/checkpoint_agent/presets/cursor.rs
|
||
|
|
||
| // 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 @@ | |
| 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,45 @@ | |
| 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`, then `model_id`, then transcript. | ||
| fn resolve_cursor_model(data: &serde_json::Value, transcript_path: Option<&str>) -> String { | ||
| 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) { | ||
| return model.to_string(); | ||
| } | ||
| } | ||
|
|
||
| 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), | ||
| crate::streams::sweep::StreamFormat::CursorJsonl, | ||
| None, | ||
| ) && !is_cursor_placeholder_model(&model) | ||
| { | ||
| return model; | ||
| } | ||
| } | ||
|
|
||
| "unknown".to_string() | ||
| } | ||
|
|
||
| fn cursor_file_path_from_tool_input(tool_input: Option<&serde_json::Value>) -> String { | ||
| tool_input | ||
| .and_then(|ti| { | ||
|
|
@@ -512,6 +550,120 @@ | |
| } | ||
| } | ||
|
|
||
| #[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_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; | ||
| 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![ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,8 @@ | |
| match format { | ||
| StreamFormat::ClaudeJsonl | ||
| | StreamFormat::CopilotEventStreamJsonl | ||
| | StreamFormat::GeminiJsonl => extract_model_from_jsonl_tail(path), | ||
| | StreamFormat::GeminiJsonl | ||
|
Check warning on line 15 in src/streams/model_extraction.rs
|
||
| | 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 @@ | |
| .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())) | ||
|
Check failure on line 107 in src/streams/model_extraction.rs
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Do not let a placeholder transcript model mask model_id — Risk: 74/100 When the hook only supplies a placeholder,
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| .or_else(|| json.get("model_id").and_then(|v| v.as_str())); | ||
|
Check failure on line 108 in src/streams/model_extraction.rs
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Cursor transcript fallback lets a placeholder
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
|
|
||
| if let Some(model) = candidate | ||
| && model != "<synthetic>" | ||
|
|
@@ -369,6 +371,23 @@ | |
| assert_eq!(result, None); | ||
| } | ||
|
|
||
| #[test] | ||
|
Check warning on line 374 in src/streams/model_extraction.rs
|
||
| 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"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 [ai] Do not let a placeholder transcript model hide its concrete model_id — Risk: 78/100
The Cursor resolver invokes this generic extractor only after both hook fields are unusable. For a Cursor transcript record containing
model: "auto"andmodel_id: "claude-opus-4-7", thisor_elsechain selects the present placeholder at line 107, never evaluatesmodel_idat line 108, and returnsauto.resolve_cursor_modelthen rejects that placeholder (cursor.rs:209) and returnsunknown(cursor.rs:215), despite the transcript carrying a concrete model identity. This is reachable on postToolUse when Cursor's hook reports Auto/default and the transcript is the fallback source, so persisted attribution is incorrectly normalized tocursor/unknown-modelrather than the actual model.src/commands/checkpoint_agent/presets/cursor.rs,src/streams/model_extraction.rs,src/authorship/working_log.rs🛠 AI fix prompt (copy & paste into your coding agent)
Flagged by Autter security & observability checks.