fix(lang): keep hidden reasoning_content in English regardless of locale (#1842/#1843)

- Add show_thinking flag to PromptSessionContext
- When show_thinking=false, emit hidden-thinking English instruction
- Omit locale-reinforcement bookends when user can't see thinking blocks
- Keep final-visible-reply language rule unchanged
- Add test for hidden-thinking language directive
This commit is contained in:
Hunter Bown
2026-05-26 16:38:26 -05:00
parent aa83446d6b
commit 6fce7dca38
8 changed files with 164 additions and 13 deletions
+11
View File
@@ -99,6 +99,9 @@ pub struct EngineConfig {
/// When true, the model is instructed to respond in the current locale
/// and a post-hoc translation layer replaces remaining English output.
pub translation_enabled: bool,
/// Whether user-visible transcript rendering shows thinking blocks.
/// Prompt assembly uses this to avoid localizing hidden reasoning.
pub show_thinking: bool,
/// Maximum number of assistant steps before stopping.
pub max_steps: u32,
/// Maximum number of concurrently active subagents.
@@ -194,6 +197,7 @@ impl Default for EngineConfig {
instructions: Vec::new(),
project_context_pack_enabled: true,
translation_enabled: false,
show_thinking: true,
max_steps: 100,
max_subagents: DEFAULT_MAX_SUBAGENTS,
features: Features::with_defaults(),
@@ -455,6 +459,7 @@ impl Engine {
locale_tag: &config.locale_tag,
translation_enabled: config.translation_enabled,
model_id: &config.model,
show_thinking: config.show_thinking,
},
session.approval_mode,
);
@@ -610,6 +615,7 @@ impl Engine {
auto_approve,
approval_mode,
translation_enabled,
show_thinking,
} => {
self.handle_send_message(
content,
@@ -624,6 +630,7 @@ impl Engine {
auto_approve,
approval_mode,
translation_enabled,
show_thinking,
)
.await;
}
@@ -830,6 +837,7 @@ impl Engine {
self.session.auto_approve,
self.session.approval_mode,
self.config.translation_enabled,
self.config.show_thinking,
)
.await;
}
@@ -918,6 +926,7 @@ impl Engine {
auto_approve: bool,
approval_mode: crate::tui::approval::ApprovalMode,
translation_enabled: bool,
show_thinking: bool,
) {
// Reset cancel token for fresh turn (in case previous was cancelled)
self.reset_cancel_token();
@@ -1011,6 +1020,7 @@ impl Engine {
self.session.trust_mode = trust_mode;
self.config.trust_mode = trust_mode;
self.config.translation_enabled = translation_enabled;
self.config.show_thinking = show_thinking;
self.session.auto_approve = auto_approve;
self.session.approval_mode = if auto_approve {
crate::tui::approval::ApprovalMode::Auto
@@ -1852,6 +1862,7 @@ impl Engine {
locale_tag: &self.config.locale_tag,
translation_enabled: self.config.translation_enabled,
model_id: &self.config.model,
show_thinking: self.config.show_thinking,
},
self.session.approval_mode,
);
+22
View File
@@ -1509,6 +1509,28 @@ fn refresh_system_prompt_is_noop_when_unchanged() {
assert_eq!(engine.session.system_prompt, first_prompt);
}
#[test]
fn engine_prompt_respects_hidden_thinking_config() {
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
locale_tag: "zh-Hans".to_string(),
show_thinking: false,
..Default::default()
};
let (engine, _handle) = Engine::new(config, &Config::default());
let prompt = match engine.session.system_prompt.as_ref() {
Some(SystemPrompt::Text(text)) => text,
Some(SystemPrompt::Blocks(_)) => panic!("expected text system prompt"),
None => panic!("expected system prompt"),
};
assert!(prompt.contains("## Hidden Thinking Language"));
assert!(prompt.contains("reasoning_content"));
assert!(prompt.contains("English"));
assert!(!prompt.contains("## 语言再次提醒"));
}
fn sync_runtime_system_prompt_override(engine: &mut Engine, system_prompt: SystemPrompt) {
engine.session.compaction_summary_prompt =
extract_compaction_summary_prompt(Some(system_prompt.clone()));
+1
View File
@@ -31,6 +31,7 @@ pub enum Op {
auto_approve: bool,
approval_mode: ApprovalMode,
translation_enabled: bool,
show_thinking: bool,
},
/// Cancel the current request
+6 -5
View File
@@ -5160,6 +5160,7 @@ async fn run_exec_agent(
.lsp
.clone()
.map(crate::config::LspConfigToml::into_runtime);
let settings = crate::settings::Settings::load().unwrap_or_default();
let engine_config = EngineConfig {
model: effective_model.clone(),
@@ -5172,6 +5173,7 @@ async fn run_exec_agent(
instructions: config.instructions_paths(),
project_context_pack_enabled: config.project_context_pack_enabled(),
translation_enabled: false,
show_thinking: settings.show_thinking,
max_steps: 100,
max_subagents,
features: config.features(),
@@ -5197,11 +5199,9 @@ async fn run_exec_agent(
vision_config: config.vision_model_config(),
strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
goal_objective: None,
locale_tag: crate::localization::resolve_locale(
&crate::settings::Settings::load().unwrap_or_default().locale,
)
.tag()
.to_string(),
locale_tag: crate::localization::resolve_locale(&settings.locale)
.tag()
.to_string(),
workshop: config.workshop.clone(),
search_provider: config.search_provider(),
search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()),
@@ -5260,6 +5260,7 @@ async fn run_exec_agent(
trust_mode,
auto_approve,
translation_enabled: false,
show_thinking: settings.show_thinking,
approval_mode: if auto_approve {
crate::tui::approval::ApprovalMode::Auto
} else {
+102 -2
View File
@@ -34,6 +34,10 @@ pub struct PromptSessionContext<'a> {
/// preserving backward compatibility with existing call sites
/// that predate dynamic model injection.
pub model_id: &'a str,
/// Whether the user-visible transcript renders thinking blocks.
/// When false, the prompt should not spend localization pressure on
/// `reasoning_content` the user will never see.
pub show_thinking: bool,
}
impl Default for PromptSessionContext<'_> {
@@ -45,6 +49,7 @@ impl Default for PromptSessionContext<'_> {
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
}
}
}
@@ -104,6 +109,25 @@ fn translation_target_language_for_tag(locale_tag: &str) -> &'static str {
}
}
fn hidden_thinking_language_instruction(locale_tag: &str) -> String {
let fallback_language = translation_target_language_for_tag(locale_tag);
format!(
"\
## Hidden Thinking Language\n\
\n\
The user has disabled thinking display (`show_thinking = false`). If you emit \
`reasoning_content`, keep that hidden internal thinking in English regardless \
of the latest user-message language or `## Environment.lang`; the user will \
not see it, so localizing hidden thinking only adds language switching.\n\
\n\
The final reply is still user-visible. Follow the normal `## Language` rule \
for the final reply: mirror the latest user message, and use \
{fallback_language} only when the user message is ambiguous. If the user \
explicitly asks for a different thinking language, follow that explicit request \
for the current turn."
)
}
/// Render a `## Environment` block listing the resolved locale tag,
/// runtime version, host platform, login shell, and current working directory.
///
@@ -611,6 +635,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
)
}
@@ -657,7 +682,11 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
// in English even though `lang: zh-Hans` is set" failure mode that
// PR #1398 partially addressed. English (and unknown) locales get
// `None` and keep the previous behavior unchanged.
let preamble = locale_reinforcement_preamble(session_context.locale_tag);
let preamble = if session_context.show_thinking {
locale_reinforcement_preamble(session_context.locale_tag)
} else {
None
};
// 12. Mode prompt + project context.
// `load_project_context_with_parents` auto-generates .codewhale/instructions.md
@@ -806,8 +835,17 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
// rule immediately before it generates `reasoning_content` for the
// turn. English (and unknown) locales return `None` and the prompt
// stays byte-identical to the pre-bookend behavior.
if let Some(closer) = locale_reinforcement_closer(session_context.locale_tag) {
if let Some(closer) = session_context
.show_thinking
.then(|| locale_reinforcement_closer(session_context.locale_tag))
.flatten()
{
full_prompt = format!("{full_prompt}\n\n{closer}");
} else if !session_context.show_thinking {
full_prompt = format!(
"{full_prompt}\n\n{}",
hidden_thinking_language_instruction(session_context.locale_tag)
);
}
SystemPrompt::Text(full_prompt)
@@ -1087,6 +1125,7 @@ mod tests {
locale_tag: "zh-Hans",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
ApprovalMode::Suggest,
) {
@@ -1157,6 +1196,7 @@ mod tests {
locale_tag: "zh-Hans",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
ApprovalMode::Suggest,
) {
@@ -1184,6 +1224,58 @@ mod tests {
);
}
#[test]
fn hidden_thinking_uses_english_reasoning_without_locale_bookends() {
let tmp = tempdir().expect("tempdir");
let text = match system_prompt_for_mode_with_context_skills_session_and_approval(
AppMode::Agent,
tmp.path(),
None,
None,
None,
PromptSessionContext {
user_memory_block: None,
goal_objective: None,
project_context_pack_enabled: false,
locale_tag: "zh-Hans",
translation_enabled: false,
model_id: "codewhale",
show_thinking: false,
},
ApprovalMode::Suggest,
) {
SystemPrompt::Text(text) => text,
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
};
assert!(
text.contains("## Hidden Thinking Language"),
"hidden thinking prompt must include the request-side language override"
);
assert!(
text.contains("reasoning_content") && text.contains("English"),
"hidden thinking override must steer reasoning_content to English"
);
assert!(
text.contains("final reply") && text.contains("Simplified Chinese"),
"hidden thinking override must preserve the visible reply language"
);
assert!(
!text.contains("## 语言要求") && !text.contains("## 语言再次提醒"),
"hidden thinking prompt must not also ask for localized reasoning"
);
let hidden_pos = text
.find("## Hidden Thinking Language")
.expect("hidden thinking block present");
let hidden_header_end = hidden_pos + "## Hidden Thinking Language".len();
let after_hidden_body = &text[hidden_header_end..];
assert!(
!after_hidden_body.contains("\n## "),
"hidden thinking override must be the final top-level block; got: {after_hidden_body:?}",
);
}
#[test]
fn system_prompt_skips_locale_preamble_for_english() {
// English locale → no preamble injected. Asserts the
@@ -1202,6 +1294,7 @@ mod tests {
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
ApprovalMode::Suggest,
) {
@@ -1295,6 +1388,7 @@ mod tests {
locale_tag: "ja",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1331,6 +1425,7 @@ mod tests {
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1359,6 +1454,7 @@ mod tests {
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1416,6 +1512,7 @@ mod tests {
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1444,6 +1541,7 @@ mod tests {
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1639,6 +1737,7 @@ mod tests {
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1673,6 +1772,7 @@ mod tests {
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
},
) {
SystemPrompt::Text(text) => text,
+9 -5
View File
@@ -1611,6 +1611,9 @@ impl RuntimeThreadManager {
let allow_shell = req.allow_shell.unwrap_or(thread.allow_shell);
let trust_mode = req.trust_mode.unwrap_or(thread.trust_mode);
let auto_approve = req.auto_approve.unwrap_or(thread.auto_approve);
let show_thinking = crate::settings::Settings::load()
.unwrap_or_default()
.show_thinking;
engine
.send(Op::SendMessage {
@@ -1625,6 +1628,7 @@ impl RuntimeThreadManager {
trust_mode,
auto_approve,
translation_enabled: false,
show_thinking,
approval_mode: if auto_approve {
crate::tui::approval::ApprovalMode::Auto
} else {
@@ -1931,6 +1935,7 @@ impl RuntimeThreadManager {
.lsp
.clone()
.map(crate::config::LspConfigToml::into_runtime);
let settings = crate::settings::Settings::load().unwrap_or_default();
let engine_cfg = EngineConfig {
model: thread.model.clone(),
workspace: thread.workspace.clone(),
@@ -1942,6 +1947,7 @@ impl RuntimeThreadManager {
instructions: self.config.instructions_paths(),
project_context_pack_enabled: self.config.project_context_pack_enabled(),
translation_enabled: false,
show_thinking: settings.show_thinking,
max_steps: 100,
max_subagents: self.config.max_subagents().clamp(1, MAX_SUBAGENTS),
features: self.config.features(),
@@ -1982,11 +1988,9 @@ impl RuntimeThreadManager {
vision_config: self.config.vision_model_config(),
strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false),
goal_objective: None,
locale_tag: crate::localization::resolve_locale(
&crate::settings::Settings::load().unwrap_or_default().locale,
)
.tag()
.to_string(),
locale_tag: crate::localization::resolve_locale(&settings.locale)
.tag()
.to_string(),
workshop: self.config.workshop.clone(),
search_provider: self.config.search_provider(),
search_api_key: self.config.search.as_ref().and_then(|s| s.api_key.clone()),
+3
View File
@@ -684,6 +684,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
instructions: config.instructions_paths(),
project_context_pack_enabled: config.project_context_pack_enabled(),
translation_enabled: app.translation_enabled,
show_thinking: app.show_thinking,
// Effectively unlimited. V4 has a 1M context window and the user
// wants the model running until it's actually done. The previous cap
// of 100 hit the ceiling on long multi-step plans (wide refactors,
@@ -4147,6 +4148,7 @@ async fn dispatch_user_message(
locale_tag: app.ui_locale.tag(),
translation_enabled: app.translation_enabled,
model_id: &app.model,
show_thinking: app.show_thinking,
},
),
);
@@ -4243,6 +4245,7 @@ async fn dispatch_user_message(
auto_approve: app.mode == AppMode::Yolo,
approval_mode: app.approval_mode,
translation_enabled: app.translation_enabled,
show_thinking: app.show_thinking,
})
.await
{
+10 -1
View File
@@ -3140,6 +3140,7 @@ async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {
#[tokio::test]
async fn dispatch_user_message_records_prompt_for_cancel_restore() {
let mut app = create_test_app();
app.show_thinking = false;
let config = Config::default();
let mut engine = crate::core::engine::mock_engine_handle();
let queued = crate::tui::app::QueuedMessage::new("fix this typo\nthen retry".to_string(), None);
@@ -3153,8 +3154,16 @@ async fn dispatch_user_message_records_prompt_for_cancel_restore() {
Some("fix this typo\nthen retry")
);
match engine.rx_op.recv().await.expect("send message op") {
crate::core::ops::Op::SendMessage { content, .. } => {
crate::core::ops::Op::SendMessage {
content,
show_thinking,
..
} => {
assert_eq!(content, "fix this typo\nthen retry");
assert!(
!show_thinking,
"dispatch must carry the user's hidden-thinking setting into the engine"
);
}
other => panic!("expected SendMessage, got {other:?}"),
}