fix(streaming): keep history = typed_text + fallback when zero chars typed

pr-agent #412 反馈两条都修:

1. \"Missing fallback\" —— typer 在第一字就失败(最常见:session 开始时已处
   于 macOS Secure Input,或 SendInput / enigo 拒绝),已往返流式但 typed_chars=0。
   旧逻辑仍返 already_streamed=true → 上层跳过 inserter → 用户看不到任何内容。
   新逻辑:typed_chars=0 且 typer_failure 存在时,回退到一次性 inserter 路径
   兜底,让 polish 文本通过正常 insertion 写出。

2. \"Wrong final text\" —— SSE 中途失败但已落字(typed_chars > 0),旧逻辑给
   history 写 raw.text 而屏幕显示 partial polish,二者分叉。新逻辑:typer 持续
   累积 typed_text(屏幕实际落字内容),history 与屏幕保持一致 —— 写 typed_text,
   polish_error 注明 \"streaming polish failed mid-stream after N chars\"。

实现:typer 任务返回值由 (typed_chars, first_failure) 变为 (typed_text, first_failure),
顶层用 typed_text.chars().count() 派生 typed_chars,并把 typed_text 在两个分支
都用上。

测试:cargo test --lib 222 passed。
This commit is contained in:
baiqing committed 2026-05-12 01:33:41 +08:00
1 parent 44f1b94c32
commit a8ae91c63b
1 file changed
+43 -18
@@ -105,10 +105,13 @@ async fn run_streaming_polish(
};
// 2. 起 typer 后台任务:从 mpsc 收 delta,串行调 type_unicode_chunk。
// 同时累积 typed_text:屏幕上真正落字的内容,用于(a)SSE 中途失败时让 history
// 与用户实际看到的内容一致;(b)pr-agent #412 反馈 \"saved output diverges
// from what the user actually sees\"。
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let typer_handle = tokio::task::spawn_blocking(move || {
let mut rx = rx;
let mut total_chars: usize = 0;
let mut typed_text = String::new();
let mut first_failure: Option<String> = None;
while let Some(delta) = rx.blocking_recv() {
if first_failure.is_some() {
@@ -118,18 +121,19 @@ async fn run_streaming_polish(
}
match crate::unicode_keystroke::type_unicode_chunk(&delta) {
Ok(()) => {
total_chars += delta.chars().count();
typed_text.push_str(&delta);
}
Err(e) => {
log::error!(
"[coord] streaming_insert: type_unicode_chunk failed at typed={total_chars} chars: {e}; \
dropping remaining deltas"
"[coord] streaming_insert: type_unicode_chunk failed at typed={} chars: {e}; \
dropping remaining deltas",
typed_text.chars().count()
);
first_failure = Some(e.to_string());
}
}
}
(total_chars, first_failure)
(typed_text, first_failure)
});
// 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。
@@ -154,11 +158,12 @@ async fn run_streaming_polish(
// tx 已经被 move 进 on_delta 闭包;闭包随 polish_or_passthrough_streaming 返回
// 而 drop,typer 那侧 blocking_recv 拿到 None 自然退出。
// 4. 等 typer 把缓冲 drain 完,拿到实际打字 chars 数 + 第一条失败原因。
let (typed_chars, typer_failure) = typer_handle.await.unwrap_or_else(|e| {
// 4. 等 typer 把缓冲 drain 完,拿到实际落字的全文 + 第一条失败原因。
let (typed_text, typer_failure) = typer_handle.await.unwrap_or_else(|e| {
log::error!("[coord] streaming_insert: typer task join failed: {e}");
(0, Some(format!("typer join: {e}")))
(String::new(), Some(format!("typer join: {e}")))
});
let typed_chars = typed_text.chars().count();
log::info!("[coord] streaming_insert: typer drained, typed {typed_chars} chars");
// 5. 无论流是否成功,都恢复用户原输入源。
@@ -178,6 +183,19 @@ async fn run_streaming_polish(
typed_chars,
typer_failure
);
// 边界 case:polish 成功但 typer 在第一字就失败(最常见:session 开始时
// 已处于 Secure Input;或 SendInput / enigo 拒绝)。屏幕上一字未见,
// already_streamed=true 会让上层跳过 inserter,最终用户看不到任何内容。
// 这里显式回退到一次性兜底,让正常 inserter 路径写出 polish 结果。
// pr-agent #412 反馈 \"Missing fallback\"。
if typed_chars == 0 {
if let Some(reason) = typer_failure {
log::warn!(
"[coord] streaming_insert: zero chars typed despite polish success ({reason}); falling back to one-shot inserter"
);
return (text, Some(reason), false);
}
}
// 把 final text 写回剪贴板(默认 on,可关)。一次性路径天然走剪贴板,开关
// 默认对齐一次性行为,让 Cmd+V 重复粘贴可用。
if inner.prefs.get().streaming_insert_save_clipboard {
@@ -200,10 +218,17 @@ async fn run_streaming_polish(
"[coord] streaming_insert: clipboard save skipped (pref off)"
);
}
// typer 失败但 polish 成功的边界 case(Secure Input 中途启用之类):
// 已经流出去的字保留在屏幕上,typer_failure 进 polish_error 让用户知情。
let err = typer_failure.map(|e| format!("typing partially failed: {e}"));
(text, err, true)
// typer 中途失败但 polish 成功(typed_chars > 0 但 typer_failure 也存在):
// 屏幕只有 typed_text 这一段,history 记完整 polish 反而会让用户复盘困惑。
// 让 history 跟屏幕一致——记 typed_text,polish_error 写明 typer 截断原因。
match typer_failure {
Some(e) => (
typed_text,
Some(format!("typing partially failed: {e}")),
true,
),
None => (text, None, true),
}
}
super::StreamingPolishOutcome::UnsupportedFallback => {
log::info!("[coord] streaming_insert: dispatch reported unsupported, fall back to one-shot");
@@ -223,15 +248,15 @@ async fn run_streaming_polish(
}
super::StreamingPolishOutcome::Failed(reason) => {
log::warn!(
"[coord] streaming_insert FAILED: {reason}; typed {typed_chars} chars before failure; falling back to raw + one-shot for any remaining content"
"[coord] streaming_insert FAILED: {reason}; typed {typed_chars} chars before failure"
);
// 流式失败但已经流了一部分 chars:用户屏幕上有半截 polish,再走一次性塞
// raw.text 会重复内容。最不糟的选择:保留已流的半截作为最终输出(不再二次
// 插入),记 polish_error。如果一字都没流(typed_chars == 0),按现有失败
// 语义回到 raw 一次性兜底。
// 流式失败但已经流了一部分 chars:用户屏幕上有半截 polish。history 应当
// 跟屏幕一致 —— 记 typed_text 而不是 raw.text,否则保存内容跟用户看见的
// 内容会分叉(pr-agent #412 \"Wrong final text\" 反馈)。
// 一字都没流时 typed_text 是空串,回到 raw 一次性兜底。
if typed_chars > 0 {
(
raw.text.clone(),
typed_text,
Some(format!(
"streaming polish failed mid-stream after {typed_chars} chars: {reason}"
)),