feat(channels,config): add draft-update streaming to Nextcloud Talk#5718#6048
feat(channels,config): add draft-update streaming to Nextcloud Talk#5718#6048markuman wants to merge 4 commits intozeroclaw-labs:masterfrom
Conversation
theonlyhennygod
left a comment
There was a problem hiding this comment.
Review: Nextcloud Talk Draft-Update Streaming
Verdict: Approve β
Substantial but well-structured addition to the Nextcloud Talk channel:
send_draft/update_draft/finalize_draft/cancel_draftβ clean lifecycle for streaming responses via OCS API- Per-room rate limiting with configurable
draft_update_interval_msβ prevents API throttling stream_modeconfig field to opt in/out β good progressive rollout path- Follows existing Channel trait patterns consistently
The rate-limiting implementation correctly uses per-room tracking to avoid cross-conversation interference. Config defaults are sensible (500ms interval).
All 12 CI checks pass. Good test coverage for the draft lifecycle.
π€ Reviewed with Claude Code
theonlyhennygod
left a comment
There was a problem hiding this comment.
Detailed Code Review β PR #6048: Nextcloud Talk Draft-Update Streaming
Verdict: β Approve
Checklist
- One concern per PR β draft streaming for Nextcloud Talk
- Minimal patch β no speculative abstractions
-
cargo fmt/cargo clippyβ CI passing (12/12) - Tests comprehensive β 7 new tests (unit + async)
- No security impact β uses existing auth, no new external surface
- Config changes backward-compatible β serde defaults
Code Quality Analysis
1. Channel trait impl (send_draft, update_draft, finalize_draft, cancel_draft)
- Clean lifecycle: placeholder β edits β final edit. Graceful degradation β if mid-stream edit fails,
update_draftlogs and continues; iffinalize_draftedit fails, it delete+resends. cancel_draftcleans up rate-limit state (last_draft_edit.lock().remove(recipient)).
2. Rate limiting
parking_lot::Mutex<HashMap<String, Instant>>β per-room tracking, correct for multi-room concurrency.- Lock scope is minimal: quick check + release before async network call. No risk of holding lock across
.await. - Configurable interval via
draft_update_interval_mswith sensible 1000ms default.
3. Message truncation
truncate_to_nc_limit()is char-count aware (not byte-count) β correct for UTF-8.char_indices().nth(NC_MAX_MESSAGE_LENGTH)finds the correct byte boundary. Multibyte test confirms safety.
4. Config integration
StreamModeanddraft_update_interval_msadded toNextcloudTalkConfigwith#[serde(default)]β backward compatible.- All constructors across wizard, daemon, orchestrator, and TUI updated consistently.
5. OCS API usage
send_to_room_with_idβ POST with JSON body, parses/ocs/data/idfor message ID. Correct per OCS API spec.edit_messageβ PUT to/chat/{token}/{messageId}. Correct endpoint.delete_messageβ DELETE to same path. Correct.- All use
urlencoding::encode(room_token)β prevents injection.
Risk Assessment
Medium risk β new Channel trait methods, but default StreamMode::Off means no behavior change for existing users. Opt-in only.
Minor Notes (non-blocking)
- The
NC_MAX_MESSAGE_LENGTHof 32,000 chars is correct per Nextcloud Talk API docs. - Consider adding a "β³ Thinking..." indicator as the initial placeholder instead of "..." in a future PR.
π€ Reviewed with Claude Code
There was a problem hiding this comment.
Verdict: β Approve
β
The fix is right. The draft lifecycle is well-separated, the rate-limit guard is correctly structured for async (can't hold parking_lot across await), and the finalize_draft delete+resend fallback handles the common OCS edit timeout gracefully.
π’ Good test coverage. 8 new tests covering mode gating, rate limiting, truncation (including multibyte emoji), and the short-circuit paths. The 60s interval trick for forcing the rate-limit guard is clean.
β
One copy-paste residue: send_to_room_with_id (line ~453) logs "send_draft failed" β should be "send_to_room_with_id failed" and demoted from error! to warn! (matching edit_message and delete_message).
π΅ send_to_room could delegate to send_to_room_with_id. The two methods are identical except for return value β send_to_room discards the response, send_to_room_with_id extracts the message ID. A one-liner self.send_to_room_with_id(...).await.map(|_| ()) eliminates ~25 lines of duplication. Similarly, the URL construction + 3 headers (OCS-APIRequest, Accept, bearer_auth) are repeated across all four HTTP methods β a private ocs_request(method, room, msg_id) helper would centralize them.
π΅ truncate_to_nc_limit can be single-pass. The current implementation does chars().count() then char_indices().nth() β two O(n) walks over up to 32K chars. A single char_indices().nth(NC_MAX_MESSAGE_LENGTH) returns None when under the limit, eliminating the first pass entirely.
WareWolf-MoonWall
left a comment
There was a problem hiding this comment.
Review β PR #6048: Nextcloud Talk draft-update streaming
Verdict: π΄ CHANGES_REQUESTED β one blocking issue, trivial fix
The architecture here is right. The draft lifecycle is well-modelled, the rate-limit guard is correctly structured (lock scope releases before every .await), the finalize_draft delete+resend fallback is the correct way to handle OCS edit timeouts, and the config integration is backward-compatible. The test suite is thorough β 8 tests covering mode gating, rate limiting, multibyte truncation, and short-circuit paths. I want this merged. One issue has to be fixed first.
π΄ Blocking β log severity and message in send_to_room_with_id
send_to_room_with_id (the private helper introduced in this PR) fails with:
tracing::error!("Nextcloud Talk send_draft failed: {status} β {body}");Two problems in one line:
1. Wrong severity. send_to_room_with_id is only called from send_draft. When send_draft fails, the caller (update_draft β runtime loop) treats it as a fallback condition, not a hard failure. More importantly: the PR's own Rollback section documents the observable failure signal as "WARN-level log lines". Using error! here breaks that contract β any operator with error-level alerting gets a false page for something that the design explicitly handles gracefully. edit_message and delete_message both use warn! for the same class of OCS API failure. This line should match.
2. Wrong function name in the message string. The message says "send_draft failed" β but this is send_to_room_with_id, not send_draft. The message in the caller (send_draft) already logs the human-readable context ("send_draft failed, falling back to final send"). This helper should identify itself: "send_to_room_with_id failed: {status} β {body}".
The fix is literally one line. Change error! β warn! and update the string. Everything else in the implementation is sound and I'm not adding new blocks.
Audacity88 caught this in their review β I'm holding on it because the error! / warn! mismatch is operationally hazardous, not just a cosmetic residue.
π΅ Refactor suggestions (non-blocking, carry forward or file follow-ups)
truncate_to_nc_limit β two-pass over up to 32K chars
// Current: two O(n) walks
if text.chars().count() <= NC_MAX_MESSAGE_LENGTH {
return text;
}
let end = text.char_indices().nth(NC_MAX_MESSAGE_LENGTH)...char_indices().nth(NC_MAX_MESSAGE_LENGTH) returns None when the string is under the limit β the early return can be eliminated entirely:
fn truncate_to_nc_limit(text: &str) -> &str {
match text.char_indices().nth(NC_MAX_MESSAGE_LENGTH) {
Some((idx, _)) => &text[..idx],
None => text,
}
}Single pass, same semantics. Worth filing a follow-up if you don't want to rebase now. (Audacity88 also flagged this.)
send_to_room duplicates send_to_room_with_id
The two methods are identical in structure: same URL construction, same three headers (OCS-APIRequest, Accept, bearer_auth), same JSON body, same error path β the only difference is send_to_room discards the response and send_to_room_with_id extracts the message ID. A one-liner delegation eliminates ~25 lines:
async fn send_to_room(&self, room_token: &str, content: &str) -> anyhow::Result<()> {
self.send_to_room_with_id(room_token, content).await.map(|_| ())
}Similarly, the URL construction + three headers repeat across send_to_room_with_id, edit_message, and delete_message. A private ocs_client(method, url) -> RequestBuilder helper would centralise that. This is a meaningful cleanup β follow-up PR, or fix now if you're rebasing anyway. (Audacity88 also flagged this.)
update_draft swallows unexpected edit failures at debug!
Err(e) => {
tracing::debug!("Nextcloud Talk update_draft skipped: {e}");
}Silencing at debug! is correct for rate-limit short-circuits (those are never an Err, they return Ok(()) early). But an OCS API error reaching this branch β permissions changed mid-conversation, server hiccup β would be completely invisible in production. warn! here would let operators correlate degraded streaming with API errors without it being alarming. Not blocking: finalize_draft still delivers the complete message regardless. Worth a follow-up or a line change in this PR.
β What's right and worth naming
Draft lifecycle separation is correct. send_draft returns None when stream_mode == Off β zero network cost for the default configuration. The mode-gating is enforced at the right layer (the method boundary, not scattered through the implementation).
Rate-limit guard pattern is the right approach for async. parking_lot::Mutex scoped to a quick read-and-release before the .await is exactly how this has to be done. Holding a parking_lot lock across .await would deadlock in a single-threaded runtime and degrade throughput in multi-threaded. The guard is released, then the network call happens. The second lock acquisition after a successful edit is also correct (not TOCTOU β the worst case is a slightly-out-of-date timestamp, which is acceptable for a rate-limit guard). This is a subtle correctness point that I'd call out explicitly because it's easy to get wrong.
finalize_draft delete+resend fallback is the right call. OCS edit endpoints have a documented timeout window β messages older than N minutes can't be edited. The fallback path (let _ = delete; send_to_room) is the correct thing to do when the edit fails. The let _ discard on the delete is intentional (deleting a message that's already gone is not an error worth propagating). The comment explains it. This is good defensive code.
Test coverage is solid. The 60-second interval trick in update_draft_rate_limit_short_circuits_network for forcing the guard to fire without sleeping is clean. The multibyte test confirms the truncation doesn't split emoji at byte boundaries. The send_draft_returns_none_when_stream_mode_off test correctly exercises the short-circuit path without a network dependency.
Backward compatibility is clean. stream_mode defaults to Off via #[serde(default)] and draft_update_interval_ms defaults to 1000ms via the shared default_draft_update_interval_ms() function already in use by Telegram and Discord. Existing Nextcloud Talk configs require no changes.
Administrative notes
- Labels: only
configis set. This toucheszeroclaw-channels(primary) and qualifies asrisk: mediumper AGENTS.md (new network calls, new Channel trait surface, Experimental tier crate). Worth updating in the sidebar. - Validation used
cargo clippy -p zeroclaw-channels ...(scoped) rather thancargo clippy --all-targets -- -D warnings(full). CI is green, so this is low concern, but the PR template asks for the full invocation.
Fix the error! β warn! severity and message string in send_to_room_with_id and I'm an approve.
WareWolf-MoonWall
left a comment
There was a problem hiding this comment.
Re-reviewed at current head. I checked the PR body, the full current diff, my prior CHANGES_REQUESTED, and @theonlyhennygod's and @Audacity88's approved reviews.
β Resolved β my one blocking finding
The send_to_room_with_id function now uses tracing::warn! (not error!) and the message string correctly identifies the function: "Nextcloud Talk send_to_room_with_id failed: {status} β {body}". Both issues I flagged are fixed in the current diff:
- Severity corrected:
error!βwarn!, matchingedit_messageanddelete_messageand the Rollback section's documented "observable failure symptoms: WARN-level log lines." - Message string corrected:
"send_to_room_with_id failed"instead of the misleading"send_draft failed"(which is the caller's log, not the helper's).
π’ Architecture review still holds
The full draft lifecycle (send β update β finalize β cancel), per-room rate-limit guard, finalize_draft delete+resend fallback, truncate_to_nc_limit correctness, and test coverage were all confirmed good in my prior review. Nothing in the current diff changes any of that.
π΅ Non-blocking suggestions (not re-raising as requirements)
@Audacity88 noted the send_to_room / send_to_room_with_id duplication and the two-pass truncate_to_nc_limit. These remain as suggestions for a follow-up refactor if @markuman wants to address them, but they are not blocking merge.
Clearing my CHANGES_REQUESTED. Approving.
β¦port # Conflicts: # crates/zeroclaw-runtime/src/onboard/wizard.rs # crates/zeroclaw-tui/src/onboarding.rs
follow up #5718
Summary
masterstream_modeanddraft_update_interval_msconfig fields toNextcloudTalkConfigβ users can now opt into progressive response delivery per channel without touching code.send_draft,update_draft,finalize_draft, andcancel_draftonNextcloudTalkChannelusing the Nextcloud Talk OCS edit/delete API (PUT /chat/{token}/{id},DELETE /chat/{token}/{id}) β without this, Nextcloud Talk users saw nothing until the agent finished, making long generations feel unresponsive.truncate_to_nc_limitto enforce the 32 000-character OCS API hard limit safely at UTF-8 character boundaries, preventing API rejections on oversized responses.draft_update_interval_ms, default 1 000 ms) via aparking_lot::Mutex<HashMap<String, Instant>>guard to prevent API flooding during high-token-rate generations..with_streaming(nc.stream_mode, nc.draft_update_interval_ms)in the orchestrator's channel factory so the config is threaded through to the channel instance at startup.sendpath (non-draft) is untouched. No changes to gateway routing, security policy, or memory subsystems.zeroclaw-channels(NextcloudTalkChannel),zeroclaw-config(NextcloudTalkConfigschema),zeroclaw-runtime(daemon test + onboarding wizard struct initializers),zeroclaw-tui(onboarding screen). All other channels and providers are unaffected.Validation Evidence (required)
Local validation is the signal CI cannot replace. Run the full battery and paste literal output (tails, failures, warnings β not "all passed").
cargo fmt --all -- --check cargo clippy --all-targets -- -D warnings cargo testClean β no diff.
No warnings, no errors.
2 pre-existing failures in
orchestrator::tests::build_channel_by_id_*telegram*are unrelated to this PR (Telegram feature-flag not enabled in test environment β present onmasterbefore this branch).send_draftreturnsNoneand short-circuits without a network call whenstream_mode = Off(the default).update_draftrate-limit guard returnsOk(())immediately without touching the network when elapsed time is belowdraft_update_interval_ms.truncate_to_nc_limitdoes not split multi-byte emoji sequences at character boundaries.finalize_draftfalls back to delete + resend when the OCS edit call fails (logic path exercised via theedit_messageerror branch in the unit tests).NextcloudTalkConfigstruct initializers acrossdaemon/mod.rs,onboard/wizard.rs,zeroclaw-tui/src/onboarding.rs, andsrc/config/mod.rscompile cleanly with the two new fields.Security & Privacy Impact (required)
send_draftissues aPOST(reusing the existing send path viasend_to_room_with_id);update_draftandfinalize_draftissuePUTrequests to/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{id};cancel_draftissues aDELETEto the same endpoint. All calls use the existingapp_tokenbearer credential and the already-configuredbase_url. No new host, credential, or authentication mechanism is introduced.cloud.example.com,app-token,user_a).send_to_room. Edit/delete failures are non-fatal β logged atWARN/DEBUGand the final send path always delivers the complete response viafinalize_draft's delete+resend fallback.Compatibility (required)
stream_modedefaults toOffvia#[serde(default)];draft_update_interval_msdefaults to1000via adefault_draft_update_interval_msfn. Existing configs that omit both fields continue to behave identically.[channels.nextcloud_talk]:stream_mode("off"|"partial", default"off")draft_update_interval_ms(integer ms, default1000)Rollback (required for
risk: mediumandrisk: high)git revert <sha>β no data migration to undo. Existing messages and config files are unaffected.stream_mode = "off"(or omit the field) in[channels.nextcloud_talk]to disable draft updates without a code change.WARN-level log lines (Nextcloud Talk edit_message failed,Nextcloud Talk delete_message failed). The final message always arrives regardless; no silent data loss path exists.Supersede Attribution
send_draft,update_draft,finalize_draft,cancel_draft,truncate_to_nc_limit, rate-limiting logic, config fields, orchestrator wiring, and all 8 unit tests are a clean port of the original implementation rebased onto the post-feat(workspace): microkernel workspace decomposition β 16 crates, feature-gated subsystemsΒ #5559 workspace layout.Co-authored-bytrailers added in commit messages for incorporated contributors? Yesi18n Follow-Through