feat: stream zeroclaw sidecar responses via ChatDelta events#34
feat: stream zeroclaw sidecar responses via ChatDelta events#34
Conversation
Zeroclaw sidecar responses were fully buffered — users saw no feedback during LLM generation. Add an async streaming execution path that reads stdout line-by-line and emits ChatDelta events as lines arrive, then emits ChatFinal when the process completes. - Add `sanitize_line()` for per-line filtering with shared OnceLock regex - Add `run_zeroclaw_message_streaming()` async fn using tokio::process - Add `start_streaming()` / `send_streaming()` on both adapters - Wire streaming into doctor and install command handlers - Add 9 new sanitize_line tests No frontend changes needed — the existing `useDoctorAgent` hook already handles `doctor:chat-delta` events with replacement semantics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dev01lay2
left a comment
There was a problem hiding this comment.
PR #34 Review — Streaming zeroclaw sidecar responses
Overall solid — line-by-line streaming via BufReader::lines() + ChatDelta is the right approach, and the sanitize refactor with shared OnceLock patterns is clean. A few issues worth addressing:
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 259238390c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
dev01lay2
left a comment
There was a problem hiding this comment.
Code looks great — streaming 架构清晰,retry 逻辑的抽取很干净,sanitize_line 的拆分和测试覆盖都到位。
唯一的问题:CI 的 cargo fmt --check 挂了,有两处格式问题需要修一下。跑个 cargo fmt --all 就好。
| @@ -1,4 +1,5 @@ | |||
| pub mod adapter; | |||
| mod streaming; | |||
There was a problem hiding this comment.
BS: cargo fmt 要求 mod streaming 按字母序排在 pub mod session 之后。跑 cargo fmt --all 会自动修复。
| append_history(&session_key, "user", message); | ||
| } | ||
|
|
||
| let text = run_zeroclaw_message_streaming(prompt, &key.instance_id, &key.storage_key(), on_delta) |
There was a problem hiding this comment.
BS: 这行超长,cargo fmt 会把它拆成多行。跑 cargo fmt --all 即可。
| } | ||
|
|
||
| let status = child | ||
| .wait() |
There was a problem hiding this comment.
NBS: stream_once 每次 delta 发送完整累积文本而非增量 — 对于长回复可能有性能开销,但作为初版实现完全合理,后续可以优化为只发增量。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1348570983
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| F: Fn(&str) + Send + 'static, | ||
| { | ||
| let session_key = key.storage_key(); | ||
| append_history(&session_key, "user", message); |
There was a problem hiding this comment.
Avoid double-writing install user turns to history
send_streaming appends the user message before building the prompt, and then passes Some(message) into run_zeroclaw_streaming_turn, which appends the same user turn again (streaming.rs does this in its user_message branch). This means every install follow-up is stored twice, so subsequent prompts contain duplicated user history and quickly bloat/warp the install conversation state.
Useful? React with 👍 / 👎.
| let runtime = | ||
| tokio::runtime::Runtime::new().map_err(|e| format!("failed to initialize runtime: {e}"))?; | ||
| runtime.block_on(run_zeroclaw_retry( |
There was a problem hiding this comment.
Remove nested Tokio runtime from sync zeroclaw execution
run_zeroclaw_message now creates a new Tokio runtime and calls block_on, but this function is still called from async command handlers (for example explain_operation_error in agent_fallback.rs). In normal configured environments (where provider/env checks pass), this can panic with Tokio's "Cannot start a runtime from within a runtime" and turn recoverable fallback guidance requests into hard runtime failures.
Useful? React with 👍 / 👎.
|
I added the follow-up test coverage and pushed commit 3a7eaa9 on this branch: src/App.tsx, src/lib/sshConnectErrors.ts, clawpal-core/src/ssh/mod.rs, src/lib/tests/sshConnectErrors.test.ts. If you want a separate PR, I can rebase/cherry-pick this commit onto a fresh branch, but this is already included in PR #34 now. |
d042eb2 to
799b79c
Compare
799b79c to
57cc7f6
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 57cc7f6602
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .await?; | ||
| let session_key = key.storage_key(); | ||
| append_history(&session_key, "system", &prompt); |
There was a problem hiding this comment.
Persist system prompt before streaming the first turn
In start_streaming, the call to run_zeroclaw_streaming_turn completes before the system prompt is written to history, but run_zeroclaw_streaming_turn itself appends the assistant reply immediately (streaming.rs), so the stored transcript order becomes assistant→system instead of system→assistant. Subsequent build_prompt_with_history* calls replay that reversed order, which can distort later turns because prior assistant content is shown before the governing system instruction; the same ordering bug appears in both doctor and install streaming start paths.
Useful? React with 👍 / 👎.
| let on_delta: std::sync::Arc<dyn Fn(&str) + Send + Sync> = | ||
| std::sync::Arc::new(on_delta); | ||
| (on_delta.as_ref())(""); | ||
| run_zeroclaw_retry( |
There was a problem hiding this comment.
Clear streamed text between provider retry attempts
run_zeroclaw_message_streaming emits an empty delta only once before entering retry logic, so if an early provider/model attempt streams partial stdout and then fails, later retries run without resetting the visible buffer. When subsequent attempts fail before producing new stdout, the frontend can retain stale partial text from a failed attempt while the turn ends with an error, effectively presenting failed-attempt output as the visible assistant response.
Useful? React with 👍 / 👎.
dev01lay2
left a comment
There was a problem hiding this comment.
代码改动都很好 👍 — 之前提的 stdout/stderr 并发读取、retry 逻辑抽取、adapter 去重全都处理干净了。SSH passphrase retry 的改动也合理。
唯一的问题:CI 的 cargo fmt --check 还是挂了,process.rs 和 streaming.rs 里各有一处格式问题。跑个 cargo fmt --all 再 push 就行。
| let args = args; | ||
| async move { | ||
| stream_once(&cmd, &cfg, &env_pairs, &args, on_delta.as_ref()).await | ||
| } |
There was a problem hiding this comment.
BS: 这个 closure 的缩进 cargo fmt 要求压缩成更紧凑的写法(CI 日志里有 expected diff)。cargo fmt --all 会自动修。
| append_history(&session_key, "user", message); | ||
| } | ||
|
|
||
| let text = run_zeroclaw_message_streaming(prompt, &key.instance_id, &key.storage_key(), on_delta) |
There was a problem hiding this comment.
BS: 同上次 — 这行超长,cargo fmt 会拆成多行。cargo fmt --all 即可。
Finding 3: TokenBadge was using the global runtimeModel for cost estimation, ignoring per-session model overrides set via ModelSwitcher. Changes: - Doctor.tsx tracks sessionModelOverride state, derives effectiveModel (override ?? runtimeModel), passes it to TokenBadge - ModelSwitcher accepts onModelChange callback to notify parent when the user switches model or clears override - TokenBadge now receives the effective model so cost estimates match the actual model being used for the session Note: Findings 1 & 2 (streaming runtime override + usage) reference run_zeroclaw_message_streaming / stream_once which exist only in PR #34 (feat/zeroclaw-streaming-response), not in this branch's base. Will wire session override + usage into the streaming path when #34 merges.
📊 Test Coverage Report
Coverage measured by |
🧪 Test Coverage ReportPR #34: feat: stream zeroclaw sidecar responses via ChatDelta events Rust Tests (clawpal-core)
TypeScript Tests (bun)
Updated: 2026-03-03 02:36 UTC · clawpal-pr-coverage bot |
Summary
ChatDeltaevents in real time, replacing the fully-bufferedCommand::output()approachsanitize_line()per-line filter with sharedOnceLock<Regex>patterns, refactoringsanitize_output()to share the same helpersstart_streaming()/send_streaming()inherent async methods on bothZeroclawDoctorAdapterandZeroclawInstallAdapter(sync trait stays unchanged)doctor_start_diagnosis,doctor_send_message,doctor_approve_invoke,install_start_session,install_send_messageNo frontend changes needed —
RuntimeEvent::ChatDeltaalready exists in the type system,doctor_runtime_bridgealready maps it todoctor:chat-delta, anduseDoctorAgentalready handles it with replacement semantics.Closes #26
Test plan
cargo buildpassescargo test— all 131 lib tests + 9 new sanitize_line integration tests pass (1 pre-existing unrelated failure excluded)🤖 Generated with Claude Code