Skip to content

feat: stream zeroclaw sidecar responses via ChatDelta events#34

Open
Keith-CY wants to merge 5 commits intomainfrom
feat/zeroclaw-streaming-response
Open

feat: stream zeroclaw sidecar responses via ChatDelta events#34
Keith-CY wants to merge 5 commits intomainfrom
feat/zeroclaw-streaming-response

Conversation

@Keith-CY
Copy link
Collaborator

@Keith-CY Keith-CY commented Mar 2, 2026

Summary

  • Add async streaming execution path for zeroclaw sidecar responses — stdout is read line-by-line and emitted as ChatDelta events in real time, replacing the fully-buffered Command::output() approach
  • Add sanitize_line() per-line filter with shared OnceLock<Regex> patterns, refactoring sanitize_output() to share the same helpers
  • Add start_streaming() / send_streaming() inherent async methods on both ZeroclawDoctorAdapter and ZeroclawInstallAdapter (sync trait stays unchanged)
  • Wire streaming into all 5 command handlers: doctor_start_diagnosis, doctor_send_message, doctor_approve_invoke, install_start_session, install_send_message

No frontend changes needed — RuntimeEvent::ChatDelta already exists in the type system, doctor_runtime_bridge already maps it to doctor:chat-delta, and useDoctorAgent already handles it with replacement semantics.

Closes #26

Test plan

  • cargo build passes
  • cargo test — all 131 lib tests + 9 new sanitize_line integration tests pass (1 pre-existing unrelated failure excluded)
  • Manual: start a doctor diagnosis and observe progressive text appearing in the chat bubble
  • Manual: verify tool-call invocations still work (tool intent parsed from final text)
  • Manual: verify provider retry works (misconfigure first provider, confirm second streams correctly)

🤖 Generated with Claude Code

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>
Copy link
Collaborator

@dev01lay2 dev01lay2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Copy link
Collaborator

@dev01lay2 dev01lay2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks great — streaming 架构清晰,retry 逻辑的抽取很干净,sanitize_line 的拆分和测试覆盖都到位。

唯一的问题:CI 的 cargo fmt --check 挂了,有两处格式问题需要修一下。跑个 cargo fmt --all 就好。

@@ -1,4 +1,5 @@
pub mod adapter;
mod streaming;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BS: 这行超长,cargo fmt 会把它拆成多行。跑 cargo fmt --all 即可。

}

let status = child
.wait()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NBS: stream_once 每次 delta 发送完整累积文本而非增量 — 对于长回复可能有性能开销,但作为初版实现完全合理,后续可以优化为只发增量。

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +922 to +924
let runtime =
tokio::runtime::Runtime::new().map_err(|e| format!("failed to initialize runtime: {e}"))?;
runtime.block_on(run_zeroclaw_retry(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@Keith-CY
Copy link
Collaborator Author

Keith-CY commented Mar 2, 2026

Added passphrase retry follow-up on top of #34: commit 3a7eaa9 fixes SSH passphrase fallback when host auth errors are retryable even if host entry not yet in local SSH list; also broadens retry hint matching for password/key-encrypted errors and adds unit coverage in .

@Keith-CY
Copy link
Collaborator Author

Keith-CY commented Mar 2, 2026

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.

@Keith-CY Keith-CY force-pushed the feat/zeroclaw-streaming-response branch from d042eb2 to 799b79c Compare March 2, 2026 08:46
@Keith-CY Keith-CY force-pushed the feat/zeroclaw-streaming-response branch from 799b79c to 57cc7f6 Compare March 2, 2026 08:50
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +141 to +143
.await?;
let session_key = key.storage_key();
append_history(&session_key, "system", &prompt);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +1064 to +1067
let on_delta: std::sync::Arc<dyn Fn(&str) + Send + Sync> =
std::sync::Arc::new(on_delta);
(on_delta.as_ref())("");
run_zeroclaw_retry(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Collaborator

@dev01lay2 dev01lay2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

代码改动都很好 👍 — 之前提的 stdout/stderr 并发读取、retry 逻辑抽取、adapter 去重全都处理干净了。SSH passphrase retry 的改动也合理。

唯一的问题:CI 的 cargo fmt --check 还是挂了,process.rsstreaming.rs 里各有一处格式问题。跑个 cargo fmt --all 再 push 就行。

let args = args;
async move {
stream_once(&cmd, &cfg, &env_pairs, &args, on_delta.as_ref()).await
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BS: 同上次 — 这行超长,cargo fmt 会拆成多行。cargo fmt --all 即可。

dev01lay2 added a commit that referenced this pull request Mar 2, 2026
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.
@dev01lay2
Copy link
Collaborator

📊 Test Coverage Report

Metric Base (main) PR (feat/zeroclaw-streaming-response) Delta
Lines 62.76% (3912/6233) 63.05% (3923/6222) 🟢 +0.29%
Functions 57.69% (454/787) 57.99% (457/788) 🟢 +0.30%
Regions 62.89% (6324/10055) 63.07% (6338/10049) 🟢 +0.18%

Coverage measured by cargo llvm-cov (clawpal-core + clawpal-cli). Updated at 2026-03-03T01:50:30Z.

@dev01lay2
Copy link
Collaborator

dev01lay2 commented Mar 3, 2026

🧪 Test Coverage Report

PR #34: feat: stream zeroclaw sidecar responses via ChatDelta events
Branch: feat/zeroclaw-streaming-responsemain

Rust Tests (clawpal-core)

main (base) feat/zeroclaw-streaming-response (PR) Δ
✅ Pass 139 140 1
❌ Fail 1 0 -1
⏭️ Skip 0 0

TypeScript Tests (bun)

main (base) feat/zeroclaw-streaming-response (PR) Δ
✅ Pass 9 -9
❌ Fail 0 0
📊 Line Coverage 100.00% N/A%

Updated: 2026-03-03 02:36 UTC · clawpal-pr-coverage bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Zeroclaw: Add streaming response support

2 participants