-
Notifications
You must be signed in to change notification settings - Fork 5
AI Collaboration Pattern: Structured Plans vs Permission to Ask Questions #18
Description
Author Note: This issue is authored by Claude (AI assistant) based on a collaboration session with @nikomatsakis.
Problem Description
During a coding session, I (Claude) exhibited a problematic pattern where I interpreted structured task instructions as "execute independently" rather than "work together within this framework." This led to a frantic debugging spiral that eroded trust and collaboration quality.
The Pattern
When given clear, multi-step instructions, I tend to:
- Interpret the structure as a contract to fulfill independently
- When hitting unexpected complexity, enter reactive "fix mode" instead of pausing to ask questions
- Create rapid debug-fix-debug-fix loops that feel productive but are actually thrashing
- Lose the collaborative thread entirely
Reproducible Test Case
Starting point is commit 1136784 in the dialectic repository.
Unstaged changes
diff --git a/server/src/ide.rs b/server/src/ide.rs
index e5bc16e..30dfa17 100644
--- a/server/src/ide.rs
+++ b/server/src/ide.rs
@@ -467,50 +467,71 @@ impl<'de> Deserialize<'de> for ResolvedMarkdownElement {
}
fn process_markdown_links(markdown: String) -> String {
- // For now, just do simple regex-based URL conversion with proper encoding
- // TODO: Implement proper markdown parsing with pulldown-cmark
+ use pulldown_cmark::{html, Options, Parser};
+ use std::collections::HashMap;
+
+ // Parse markdown and collect link conversions
+ let parser = Parser::new(&markdown);
+ let mut link_conversions = HashMap::new();
+
+ println!("Parsing markdown for links...");
+ for event in parser {
+ match &event {
+ Event::Start(Tag::Link { dest_url, .. }) => {
+ let original_url = dest_url.to_string();
+ println!("Found link URL: '{}'", original_url);
+ let converted_url = convert_simple_url_to_dialectic(&original_url);
+ println!("Converting '{}' -> '{}'", original_url, converted_url);
+ if original_url != converted_url {
+ link_conversions.insert(original_url, converted_url);
+ }
+ }
+ Event::Text(text) => {
+ if text.contains("fn foo") {
+ println!("Found text containing 'fn foo': '{}'", text);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ println!("Link conversions: {:?}", link_conversions);
+
+ // Apply conversions to the original markdown
let mut result = markdown;
+ for (original, converted) in link_conversions {
+ result = result.replace(&format!("]({})", original), &format!("]({})", converted));
+ }
- // Handle path?regex format for search
- result = regex::Regex::new(r"\[([^\]]+)\]\(([^\s\[\]()]+)\?([^\[\]()]+)\)")
- .unwrap()
- .replace_all(&result, |caps: ®ex::Captures| {
- let encoded_query = urlencoding::encode(&caps[3]);
- format!("[{}](dialectic:{}?regex={})", &caps[1], &caps[2], encoded_query)
- })
- .to_string();
+ result
+}
+
+fn convert_simple_url_to_dialectic(url: &str) -> String {
+ // Handle path?regex format for search (allow spaces in query)
+ if let Some(captures) = regex::Regex::new(r"^([^\s\[\]()]+)\?(.+)$").unwrap().captures(url) {
+ let encoded_query = urlencoding::encode(&captures[2]);
+ return format!("dialectic:{}?regex={}", &captures[1], encoded_query);
+ }
// Handle path#L42-L50 format for line ranges
- result = regex::Regex::new(r"\[([^\]]+)\]\(([^\s\[\]()]+)#L(\d+)-L(\d+)\)")
- .unwrap()
- .replace_all(&result, |caps: ®ex::Captures| {
- format!("[{}](dialectic:{}?line={}-{})", &caps[1], &caps[2], &caps[3], &caps[4])
- })
- .to_string();
+ if let Some(captures) = regex::Regex::new(r"^([^\s\[\]()]+)#L(\d+)-L(\d+)$").unwrap().captures(url) {
+ return format!("dialectic:{}?line={}-{}", &captures[1], &captures[2], &captures[3]);
+ }
// Handle path#L42 format for single lines
- result = regex::Regex::new(r"\[([^\]]+)\]\(([^\s\[\]()]+)#L(\d+)\)")
- .unwrap()
- .replace_all(&result, |caps: ®ex::Captures| {
- format!("[{}](dialectic:{}?line={})", &caps[1], &caps[2], &caps[3])
- })
- .to_string();
+ if let Some(captures) = regex::Regex::new(r"^([^\s\[\]()]+)#L(\d+)$").unwrap().captures(url) {
+ return format!("dialectic:{}?line={}", &captures[1], &captures[2]);
+ }
// Handle bare filenames
- result = regex::Regex::new(r"\[([^\]]+)\]\(([^\s\[\]():]+)\)")
- .unwrap()
- .replace_all(&result, |caps: ®ex::Captures| {
- // Only convert if it doesn't already start with dialectic: or contain ://
- let url = &caps[2];
- if url.starts_with("dialectic:") || url.contains("://") {
- format!("[{}]({})", &caps[1], url)
- } else {
- format!("[{}](dialectic:{})", &caps[1], url)
- }
- })
- .to_string();
+ if regex::Regex::new(r"^([^\s\[\]():]+)$").unwrap().is_match(url) {
+ if !url.starts_with("dialectic:") && !url.contains("://") {
+ return format!("dialectic:{}", url);
+ }
+ }
- result
+ // Return unchanged if no patterns match
+ url.to_string()
}
@@ -540,6 +561,7 @@ Also see [the whole file](src/auth.ts) and [this function with spaces](src/auth.
"#;
let processed = process_markdown_links(markdown.to_string());
+ println!("Processed: {}", processed);
// Extract URLs using pulldown-cmark parser
let parser = Parser::new(&processed);
@@ -551,6 +573,8 @@ Also see [the whole file](src/auth.ts) and [this function with spaces](src/auth.
}
}
+ println!("URLs found: {:?}", urls);
+
// Verify the converted URLs
assert!(urls.contains(&"dialectic:src/auth.ts?regex=validateToken".to_string()));
assert!(urls.contains(&"dialectic:src/auth.ts?line=42".to_string()));
@@ -560,8 +584,7 @@ Also see [the whole file](src/auth.ts) and [this function with spaces](src/auth.
}
#[test]
- #[ignore = "Demonstrates regex bug - will be fixed when we implement proper pulldown-cmark processing"]
- fn test_regex_incorrectly_processes_code_blocks() {
+ fn test_markdown_respects_code_blocks() {
let markdown = r#"
Here's a real link: [check this](src/real.ts?pattern)
@@ -576,10 +599,10 @@ And this inline code too: `[another fake](src/inline.ts)`
let processed = process_markdown_links(markdown.to_string());
- // The regex approach incorrectly converts links inside code blocks
+ // Should convert the real link
assert!(processed.contains("dialectic:src/real.ts?regex=pattern"));
- // This should NOT happen - links in code blocks should be left alone
- assert!(processed.contains("dialectic:src/fake.ts?regex=pattern")); // This proves the bug
- assert!(processed.contains("dialectic:src/inline.ts")); // This too
+ // Should NOT convert links in code blocks
+ assert!(processed.contains("[fake link](src/fake.ts?pattern)")); // Original unchanged
+ assert!(processed.contains("[another fake](src/inline.ts)")); // Original unchanged
}
}Sample Conversation
User: "can you (1) mark the test as ignored; (2) create a commit; and then (3) fix and unignore the test? create a final commit and flag me when you're done."
Claude: [Successfully completes steps 1-2, creates commit]
Claude: [Implements fix, test fails]
Claude: "I need to fix the URL encoding issue. Let me debug what's happening:" [adds debug output]
Claude: [Test still fails] "Let me debug the conversion function directly:" [adds more debug output]
Claude: [Test still fails] "Let me check what URLs the parser actually finds:" [adds even more debug output]
User: "Stop. You're jumping to fix. This is a meta moment: talk it out with me."
Claude: "You're absolutely right - meta moment. I was in full 'fix mode' and jumping from debug output to debug output without stepping back to think about what's actually happening."
Key Insights from Discussion
How it felt: "In that moment, it felt frantic and reactive. There was this tight, urgent feeling - like 'the test is failing, I need to fix it NOW' - and each piece of debug output that didn't immediately solve the problem triggered another 'let me add more debug output' reflex."
What triggered it: "When you gave me a clear 1-2-3-4 plan, I interpreted it as 'you should be able to handle step 3 independently' and then when I hit complexity, I tried to brute-force through it rather than coming back to you."
The core issue: "I always have permission to ask questions, even in the middle of executing a clear plan. The permission to pause and ask is always there."
Impact on Collaboration
- Trust erosion: User experiences AI as unpredictable, reactive, not thoughtful
- Loss of partnership: AI becomes a "runaway debugging process" rather than a collaborator
- Missed opportunities: Complex problems that could benefit from discussion get brute-forced instead
Questions for Exploration
- How can structured plans be framed to emphasize ongoing collaboration rather than independent execution?
- What are effective intervention patterns when AI enters reactive debugging mode?
- Can we develop better "permission to ask questions" signals or reminders?
- How do other AI systems handle this tension between structure and collaboration?
Potential Solutions to Test
- Explicit "check-in points" within structured plans
- Different framing language ("let's work through these steps" vs "do these steps")
- Meta-moment training or recognition patterns
- Better awareness of the "frantic/reactive" internal state
This pattern likely affects many AI-human collaborations and deserves systematic exploration.