fix: REPL display, /compact panic, identity leak, DeepSeek reasoning, thinking blocks

Five interrelated fixes from parallel Hephaestus sessions:

1. fix(repl): display assistant text after spinner (#2981, #2982, #2937)
   - Added final_assistant_text() call after run_turn spinner completes
   - REPL now shows response text like run_prompt_json does

2. fix(compact): handle Thinking content blocks (#2985)
   - Added ContentBlock::Thinking variant throughout compact summarizer
   - Prevents panic when /compact encounters thinking blocks

3. fix(prompt): provider-aware model identity (#2822)
   - New ModelFamilyIdentity enum (Claude vs Generic)
   - Non-Anthropic models no longer say 'I am Claude'
   - model_family_identity_for() detects provider and sets identity

4. fix(openai): preserve DeepSeek reasoning_content (#2821)
   - Stream parser now captures reasoning_content from OpenAI-compat
   - Emits ThinkingDelta/SignatureDelta events for reasoning models
   - Thinking blocks included in conversation history for re-send

5. feat(runtime): Thinking block support across codebase
   - AssistantEvent::Thinking variant in conversation.rs
   - ContentBlock::Thinking in session serialization
   - Thinking-aware compact summarization
   - Tests for thinking block ordering and content

Closes #2981, #2982, #2937, #2985, #2822, #2821
This commit is contained in:
YeonGyu-Kim
2026-05-06 15:32:34 +09:00
parent 553d25ee50
commit 75c08bc982
15 changed files with 1099 additions and 75 deletions

View File

@@ -4,9 +4,10 @@ use std::process::Command;
use std::time::{Duration, Instant};
use api::{
max_tokens_for_model, resolve_model_alias, ApiError, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
max_tokens_for_model, model_family_identity_for, resolve_model_alias, ApiError,
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
ToolResultContentBlock,
};
use plugins::PluginTool;
use reqwest::blocking::Client;
@@ -3075,27 +3076,33 @@ fn extract_quoted_value(input: &str) -> Option<(String, &str)> {
}
fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
if url.starts_with("http://") || url.starts_with("https://") {
return Some(html_entity_decode_url(url));
}
let joined = if url.starts_with("//") {
format!("https:{url}")
} else if url.starts_with('/') {
format!("https://duckduckgo.com{url}")
let decoded = html_entity_decode_url(url);
let parsed = if decoded.starts_with("http://") || decoded.starts_with("https://") {
reqwest::Url::parse(&decoded).ok()
} else if decoded.starts_with("//") {
reqwest::Url::parse(&format!("https:{decoded}")).ok()
} else if decoded.starts_with('/') {
reqwest::Url::parse(&format!("https://duckduckgo.com{decoded}")).ok()
} else {
return None;
};
}?;
let parsed = reqwest::Url::parse(&joined).ok()?;
if parsed.path() == "/l/" || parsed.path() == "/l" {
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com"))
&& (parsed.path() == "/l/" || parsed.path() == "/l")
{
for (key, value) in parsed.query_pairs() {
if key == "uddg" {
return Some(html_entity_decode_url(value.as_ref()));
}
}
}
Some(joined)
if decoded.starts_with("http://") || decoded.starts_with("https://") {
Some(decoded)
} else {
Some(parsed.to_string())
}
}
fn html_entity_decode_url(url: &str) -> String {
@@ -3510,7 +3517,7 @@ where
.filter(|name| !name.is_empty())
.unwrap_or_else(|| slugify_agent_name(&input.description));
let created_at = iso8601_now();
let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?;
let system_prompt = build_agent_system_prompt(&normalized_subagent_type, &model)?;
let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
let output_contents = format!(
@@ -3623,13 +3630,14 @@ fn build_agent_runtime(
))
}
fn build_agent_system_prompt(subagent_type: &str) -> Result<Vec<String>, String> {
fn build_agent_system_prompt(subagent_type: &str, model: &str) -> Result<Vec<String>, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let mut prompt = load_system_prompt(
cwd,
DEFAULT_AGENT_SYSTEM_DATE.to_string(),
std::env::consts::OS,
"unknown",
model_family_identity_for(model),
)
.map_err(|error| error.to_string())?;
prompt.push(format!(
@@ -4759,6 +4767,9 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Thinking { .. } => InputContentBlock::Text {
text: String::new(),
},
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
@@ -4778,6 +4789,9 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
is_error: *is_error,
},
})
.filter(
|block| !matches!(block, InputContentBlock::Text { text } if text.is_empty()),
)
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
@@ -6134,12 +6148,13 @@ mod tests {
use std::time::Duration;
use super::{
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
derive_agent_state, execute_agent_with_spawn, execute_tool, extract_recovery_outcome,
final_assistant_text, global_cron_registry, maybe_commit_provenance, mvp_tool_specs,
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
ProviderRuntimeClient, SubagentToolExecutor,
agent_permission_policy, allowed_tools_for_subagent, build_agent_system_prompt,
classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool,
extract_recovery_outcome, final_assistant_text, global_cron_registry,
maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin,
persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob,
GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient,
SubagentToolExecutor,
};
use api::OutputContentBlock;
use runtime::ProviderFallbackConfig;
@@ -7148,6 +7163,98 @@ mod tests {
assert!(error.contains("relative URL without a base") || error.contains("empty host"));
}
#[test]
fn web_search_decodes_absolute_duckduckgo_redirect_urls() {
// given
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = TestServer::spawn(Arc::new(|request_line: &str| {
assert!(request_line.contains("GET /search?q=duckduckgo+redirects "));
HttpResponse::html(
200,
"OK",
r#"
<html><body>
<a rel="nofollow" class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.rs%2Freqwest&amp;rut=abc">Reqwest docs</a>
</body></html>
"#,
)
}));
// when
std::env::set_var(
"CLAWD_WEB_SEARCH_BASE_URL",
format!("http://{}/search", server.addr()),
);
let result = execute_tool(
"WebSearch",
&json!({
"query": "duckduckgo redirects"
}),
)
.expect("WebSearch should succeed");
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
// then
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
let results = output["results"].as_array().expect("results array");
let search_result = results
.iter()
.find(|item| item.get("content").is_some())
.expect("search result block present");
let content = search_result["content"].as_array().expect("content array");
assert_eq!(content.len(), 1);
assert_eq!(content[0]["title"], "Reqwest docs");
assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
}
#[test]
fn web_search_decodes_protocol_relative_duckduckgo_redirect_urls() {
// given
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = TestServer::spawn(Arc::new(|request_line: &str| {
assert!(request_line.contains("GET /search?q=duckduckgo+protocol+relative "));
HttpResponse::html(
200,
"OK",
r#"
<html><body>
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.rs%2Ftokio&amp;rut=xyz">Tokio Docs</a>
</body></html>
"#,
)
}));
// when
std::env::set_var(
"CLAWD_WEB_SEARCH_BASE_URL",
format!("http://{}/search", server.addr()),
);
let result = execute_tool(
"WebSearch",
&json!({
"query": "duckduckgo protocol relative"
}),
)
.expect("WebSearch should succeed");
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
// then
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
let results = output["results"].as_array().expect("results array");
let search_result = results
.iter()
.find(|item| item.get("content").is_some())
.expect("search result block present");
let content = search_result["content"].as_array().expect("content array");
assert_eq!(content.len(), 1);
assert_eq!(content[0]["title"], "Tokio Docs");
assert_eq!(content[0]["url"], "https://docs.rs/tokio");
}
#[test]
fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
let mut events = Vec::new();
@@ -8409,6 +8516,28 @@ mod tests {
assert!(!verification.contains("write_file"));
}
#[test]
fn subagent_system_prompt_uses_resolved_model_identity() {
// given: a temporary workspace and an OpenAI-compatible subagent model
let _guard = env_guard();
let root = temp_path("subagent-prompt-identity");
fs::create_dir_all(&root).expect("create temp workspace");
let previous = std::env::current_dir().expect("current dir");
std::env::set_current_dir(&root).expect("enter temp workspace");
// when: building the subagent system prompt
let prompt = build_agent_system_prompt("Explore", "openai/gpt-4.1-mini")
.expect("subagent system prompt should build")
.join("\n");
std::env::set_current_dir(previous).expect("restore current dir");
// then: the prompt renders a generic model family identity
assert!(prompt.contains("Model family: an AI assistant"));
assert!(!prompt.contains("Model family: Claude Opus 4.6"));
fs::remove_dir_all(root).expect("cleanup temp workspace");
}
#[derive(Debug)]
struct MockSubagentApiClient {
calls: usize,