修复: loop_state 懒创建继承+入口早退清理(#10), update_full 剥离列封 lost-update(#11), MidStream 保文不回填半截 reasoning, 标题剥标签 sanitize(#9), ANSI 源头抑制+strip 兜底(#8)

This commit is contained in:
lxy
2026-10-06 12:13:25 +08:00
parent 03dc27bc14
commit 70d6e98f82
8 changed files with 251 additions and 23 deletions
+102 -8
View File
@@ -169,10 +169,95 @@ pub fn current_shell() -> ShellType {
ShellType::default()
}
/// 剥离 ANSI 转义序列(#8 出口兜底)。
///
/// 源头抑制(build_command 注入 NO_COLOR/TERM=dumb)覆盖主流工具链,但总有漏网
/// (不读环境变量的工具/嵌套脚本转发色码)。本纯函数兜底 CSI/OSC 两类常见序列:
/// - CSI:ESC [ 参数字母(颜色/光标/擦除,如 \x1b[7m \x1b[0K)
/// - OSC:ESC ] ... BEL 或 ESC \(终端标题/超链接,罕见但同样乱码)
/// 不碰普通文本与换行,幂等(已剥离文本再剥无变化)。
pub fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
// c == ESC:按下一字符分派序列类型
match text[i + 1..].chars().next() {
Some('[') => {
// CSI:跳到终止字母(@-~)为止
let rest = &text[i + 2..];
match rest.find(|ch: char| ('\u{40}'..='\u{7e}').contains(&ch)) {
Some(end) => {
// 跳过整个序列: advance 到 i+2+end 的下一字符
let skip = 2 + end + 1;
for _ in 1..skip {
chars.next();
}
}
None => {
// 流式截断的残缺序列(无终止符):丢弃剩余全部
for _ in (i + 1)..text.len() {
chars.next();
}
}
}
}
Some(']') => {
// OSC:到 BEL(\x07) 或 ST(ESC \)为止
let rest = &text[i + 2..];
let bel = rest.find('\x07');
let st = rest.find("\x1b\\");
match (bel, st) {
(Some(b), None) => {
// 已消费 ESC,再消费 i+1..=i+2+b(含 BEL)共 b+2 个
for _ in 0..(2 + b) {
chars.next();
}
}
(_, Some(s)) => {
// 再消费 i+1..=i+2+s+1(含 ST 两字符)共 s+3 个
for _ in 0..(2 + s + 1) {
chars.next();
}
}
(None, None) => {
for _ in (i + 1)..text.len() {
chars.next();
}
}
}
}
_ => {
// 两字符转义(ESC \ ESC ( 等):丢 ESC 保留后字符?保守双丢后单字符
chars.next();
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// #8 ANSI 剥离:CSI 色码/擦除/流式残缺序列/OSC/普通文本不动
#[test]
fn strip_ansi_basic() {
assert_eq!(strip_ansi("\x1b[7ma\x1b[0mb"), "ab");
assert_eq!(strip_ansi("\x1b[2K\x1b[1Gdone"), "done");
assert_eq!(strip_ansi("plain text\nline2"), "plain text\nline2");
// 流式残缺(无终止符):丢残序列
assert_eq!(strip_ansi("ok\x1b[3"), "ok");
// OSC BEL 终止
assert_eq!(strip_ansi("\x1b]0;title\x07body"), "body");
// 幂等
let once = strip_ansi("\x1b[32mgreen\x1b[0m");
assert_eq!(strip_ansi(&once), once);
}
/// 死缓存回归测试:probe_pwsh 写入后,Default 同步路径必须读到同一缓存值。
///
/// 历史 bug:probe_pwsh_cached() 与 probe_pwsh() 各持独立 OnceLock,Default 读的永空。
@@ -312,6 +397,13 @@ fn build_command(request: ShellRequest) -> tokio::process::Command {
for (key, value) in &request.env {
cmd.env(key, value);
}
// #8 ANSI 裸露根治(源头抑制):非交互管道下多数 CLI 仍探测 TERM 发色码,cargo/ls 等
// 输出 \x1b[Nm 原样透传前端显示为 ␛[7m 乱码。注入 NO_COLOR(https://no-color.org 标准)+
// TERM=dumb+FORCE_COLOR=0 三重信号,主流工具链(cargo/npm/git/ls/pytest)管道下停发色码。
// 调用方显式传同名 env 时以调用方为准(后写覆盖,见上循环顺序)。
cmd.env("NO_COLOR", "1");
cmd.env("TERM", "dumb");
cmd.env("FORCE_COLOR", "0");
cmd
}
@@ -346,8 +438,8 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let duration = start.elapsed().as_millis() as u64;
Ok(ShellResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
stdout: strip_ansi(&String::from_utf8_lossy(&output.stdout)),
stderr: strip_ansi(&String::from_utf8_lossy(&output.stderr)),
exit_code: output.status.code(),
duration_ms: duration,
})
@@ -430,21 +522,23 @@ where
let status = status?;
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
while let Some((kind, line)) = rx.recv().await {
let clean = strip_ansi(&line);
match kind {
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
}
on_output(kind, &line);
on_output(kind, &clean);
}
break status;
}
// 收到一行 → 累积 + 回调
Some((kind, line)) = rx.recv() => {
let clean = strip_ansi(&line);
match kind {
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
}
on_output(kind, &line);
on_output(kind, &clean);
}
}
};
@@ -199,13 +199,17 @@ impl_repo!(
)
},
update => |conn, rec| {
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式):
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。
conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, loop_state = ?14 WHERE id = ?15",
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.loop_state, rec.id
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id
],
)
}