Skip to content

feat(channels,config): add draft-update streaming to Nextcloud Talk#5718#6048

Open
markuman wants to merge 4 commits intozeroclaw-labs:masterfrom
markuman:nextcloud-draft-support
Open

feat(channels,config): add draft-update streaming to Nextcloud Talk#5718#6048
markuman wants to merge 4 commits intozeroclaw-labs:masterfrom
markuman:nextcloud-draft-support

Conversation

@markuman
Copy link
Copy Markdown
Contributor

follow up #5718

Summary

  • Base branch: master
  • What changed and why:
    • Added stream_mode and draft_update_interval_ms config fields to NextcloudTalkConfig β€” users can now opt into progressive response delivery per channel without touching code.
    • Implemented send_draft, update_draft, finalize_draft, and cancel_draft on NextcloudTalkChannel using 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.
    • Added truncate_to_nc_limit to enforce the 32 000-character OCS API hard limit safely at UTF-8 character boundaries, preventing API rejections on oversized responses.
    • Added per-room rate-limiting (draft_update_interval_ms, default 1 000 ms) via a parking_lot::Mutex<HashMap<String, Instant>> guard to prevent API flooding during high-token-rate generations.
    • Wired .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.
  • Scope boundary: No other channel was modified. The existing send path (non-draft) is untouched. No changes to gateway routing, security policy, or memory subsystems.
  • Blast radius: zeroclaw-channels (NextcloudTalkChannel), zeroclaw-config (NextcloudTalkConfig schema), zeroclaw-runtime (daemon test + onboarding wizard struct initializers), zeroclaw-tui (onboarding screen). All other channels and providers are unaffected.
  • Linked issue(s): Supersedes feat(channels,config): add draft-update streaming to Nextcloud TalkΒ #5718 (closed due to merge conflicts from workspace split in feat(workspace): microkernel workspace decomposition β€” 16 crates, feature-gated subsystemsΒ #5559)

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 test
  • Commands run and tail output:
cargo fmt --all -- --check

Clean β€” no diff.

cargo clippy -p zeroclaw-channels -p zeroclaw-config -p zeroclaw-runtime -p zeroclaw-tui -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 38s

No warnings, no errors.

cargo test -p zeroclaw-channels -p zeroclaw-config -p zeroclaw-runtime -p zeroclaw-tui
test result: ok. 933 passed; 0 failed; 0 ignored  (zeroclaw-config, zeroclaw-runtime, zeroclaw-tui)

# nextcloud_talk module specifically:
test nextcloud_talk::tests::supports_draft_updates_off_by_default ... ok
test nextcloud_talk::tests::supports_draft_updates_true_when_partial ... ok
test nextcloud_talk::tests::truncate_to_nc_limit_short_text_unchanged ... ok
test nextcloud_talk::tests::truncate_to_nc_limit_exact_limit_unchanged ... ok
test nextcloud_talk::tests::truncate_to_nc_limit_over_limit_is_truncated ... ok
test nextcloud_talk::tests::truncate_to_nc_limit_multibyte_safe ... ok
test nextcloud_talk::tests::update_draft_rate_limit_short_circuits_network ... ok
test nextcloud_talk::tests::send_draft_returns_none_when_stream_mode_off ... ok
test result: ok. 24 passed; 0 failed

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 on master before this branch).

  • Beyond CI β€” what did you manually verify:
    • send_draft returns None and short-circuits without a network call when stream_mode = Off (the default).
    • update_draft rate-limit guard returns Ok(()) immediately without touching the network when elapsed time is below draft_update_interval_ms.
    • truncate_to_nc_limit does not split multi-byte emoji sequences at character boundaries.
    • finalize_draft falls back to delete + resend when the OCS edit call fails (logic path exercised via the edit_message error branch in the unit tests).
    • All existing NextcloudTalkConfig struct initializers across daemon/mod.rs, onboard/wizard.rs, zeroclaw-tui/src/onboarding.rs, and src/config/mod.rs compile cleanly with the two new fields.
  • If any command was intentionally skipped, why: Live end-to-end draft editing against a real Nextcloud Talk server; the OCS edit/delete API requires a running Nextcloud instance. The rate-limit guard and mode-gating unit tests cover the logic paths that would be exercised without a live server.

Security & Privacy Impact (required)

  • New permissions, capabilities, or file system access scope? No
  • New external network calls? Yes β€” send_draft issues a POST (reusing the existing send path via send_to_room_with_id); update_draft and finalize_draft issue PUT requests to /ocs/v2.php/apps/spreed/api/v1/chat/{token}/{id}; cancel_draft issues a DELETE to the same endpoint. All calls use the existing app_token bearer credential and the already-configured base_url. No new host, credential, or authentication mechanism is introduced.
  • Secrets / tokens / credentials handling changed? No
  • PII, real identities, or personal data in diff, tests, fixtures, or docs? No β€” test data uses placeholder values (cloud.example.com, app-token, user_a).
  • Risk and mitigation: The new OCS calls use the same HTTP client and authentication as the existing send_to_room. Edit/delete failures are non-fatal β€” logged at WARN/DEBUG and the final send path always delivers the complete response via finalize_draft's delete+resend fallback.

Compatibility (required)

  • Backward compatible? Yes β€” stream_mode defaults to Off via #[serde(default)]; draft_update_interval_ms defaults to 1000 via a default_draft_update_interval_ms fn. Existing configs that omit both fields continue to behave identically.
  • Config / env / CLI surface changed? Yes β€” two new optional fields on [channels.nextcloud_talk]:
    • stream_mode ("off" | "partial", default "off")
    • draft_update_interval_ms (integer ms, default 1000)
  • Upgrade steps: None required. Both fields are opt-in; no existing config file needs updating.

Rollback (required for risk: medium and risk: high)

  • Fast rollback command/path: git revert <sha> β€” no data migration to undo. Existing messages and config files are unaffected.
  • Feature flags or config toggles: Set stream_mode = "off" (or omit the field) in [channels.nextcloud_talk] to disable draft updates without a code change.
  • Observable failure symptoms: OCS edit/delete errors appear as 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

i18n Follow-Through

  • N.A. β€” no user-facing strings or documentation changed in this PR.

@github-actions github-actions Bot added the config Auto scope: src/config/** changed. label Apr 23, 2026
@theonlyhennygod theonlyhennygod self-assigned this Apr 23, 2026
Copy link
Copy Markdown
Collaborator

@theonlyhennygod theonlyhennygod left a comment

Choose a reason for hiding this comment

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

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_mode config 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

Copy link
Copy Markdown
Collaborator

@theonlyhennygod theonlyhennygod left a comment

Choose a reason for hiding this comment

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

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_draft logs and continues; if finalize_draft edit fails, it delete+resends.
  • cancel_draft cleans 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_ms with 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

  • StreamMode and draft_update_interval_ms added to NextcloudTalkConfig with #[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/id for 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_LENGTH of 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

Copy link
Copy Markdown
Collaborator

@Audacity88 Audacity88 left a comment

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator

@WareWolf-MoonWall WareWolf-MoonWall left a comment

Choose a reason for hiding this comment

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

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 config is set. This touches zeroclaw-channels (primary) and qualifies as risk: medium per 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 than cargo 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.

@singlerider singlerider added enhancement New feature or request risk: medium Auto risk: src/** or dependency/config changes. size: M Auto size: 251-500 non-doc changed lines. channel:nextcloud-talk needs-author-action Author action required before merge labels Apr 29, 2026
@markuman markuman requested a review from WareWolf-MoonWall May 4, 2026 14:26
Copy link
Copy Markdown
Collaborator

@WareWolf-MoonWall WareWolf-MoonWall left a comment

Choose a reason for hiding this comment

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

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:

  1. Severity corrected: error! β†’ warn!, matching edit_message and delete_message and the Rollback section's documented "observable failure symptoms: WARN-level log lines."
  2. 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

config Auto scope: src/config/** changed. enhancement New feature or request needs-author-action Author action required before merge risk: medium Auto risk: src/** or dependency/config changes. size: M Auto size: 251-500 non-doc changed lines.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants