Skip to content

feat(channels): add Discord streaming responses and typing indicator#1389

Closed
Chiytako wants to merge 2 commits intoagentscope-ai:mainfrom
Chiytako:feat/discord-streaming
Closed

feat(channels): add Discord streaming responses and typing indicator#1389
Chiytako wants to merge 2 commits intoagentscope-ai:mainfrom
Chiytako:feat/discord-streaming

Conversation

@Chiytako
Copy link
Copy Markdown
Contributor

Summary

Add edit-based streaming and typing indicator support to the Discord channel. While the LLM generates, a live-updating draft message is shown and edited in place; a typing indicator is sent every 10 s to keep it visible. Both are opt-in via a new streaming_mode config field.

Related Issue

Closes #1296

Security Considerations

None — no changes to auth flow, permission model, or credential handling. The existing bot token is reused for REST calls; no new secrets are introduced.

Type of Change

  • Bug fix
  • New feature (non-breaking)
  • Breaking change
  • Documentation update

Changes

src/copaw/app/channels/discord_/channel.py (modified)

  • Adds DiscordDraftStream class to manage send → edit → flush → clear lifecycle with throttle control (~1.2 s between edits) to stay within Discord rate limits.
  • Adds DiscordTypingController class to send /typing every 10 s with a max-duration cap and clean stop() cleanup.
  • Overrides _run_process_loop to integrate the streaming and typing logic.
  • Implements auto-splitting for messages that exceed Discord's 2000-character limit during streaming.
  • Falls back to legacy single-send behavior when streaming_mode = "off".

src/copaw/config/config.py (modified)

  • Adds streaming_mode: Literal["off", "partial"] = "off" to DiscordConfig
  • Default "off" preserves full backward compatibility.

tests/unit/channels/test_discord_streaming.py (new file)

  • Adds robust unit tests covering the new streaming classes and config wiring.

How to Test

  1. Set streaming_mode: "partial" in your Discord channel config
  2. Send a prompt that produces a long response
  3. Verify the bot shows a typing indicator and edits the message live as tokens arrive
  4. Set streaming_mode: "off" and confirm legacy behavior is unchanged

Checklist

  • pre-commit run --all-files passed
  • Manually tested with a live Discord bot
  • Default config (streaming_mode: "off") preserves existing behavior
  • Error path clears draft message cleanly

@Chiytako Chiytako requested a deployment to maintainer-approved March 12, 2026 18:30 — with GitHub Actions Waiting
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the user experience for the Discord channel by introducing real-time streaming capabilities for LLM responses and a visible typing indicator. Instead of waiting for a complete response, users will now see a live-updating draft message and know the bot is actively processing their request, making interactions feel more dynamic and responsive. The feature is opt-in via a new configuration setting, ensuring backward compatibility.

Highlights

  • Real-time Streaming Responses: Implemented edit-based streaming for Discord, allowing a live-updating draft message to be shown as the LLM generates its response, enhancing user experience.
  • Typing Indicator: Introduced a Discord typing indicator that sends periodic /typing events every 10 seconds to keep the bot visible during response generation, with a maximum duration cap and clean stop mechanism.
  • Opt-in Configuration: Added a new streaming_mode configuration field to the Discord channel, which is opt-in with options 'off' (default) and 'partial', ensuring backward compatibility.
  • Draft Stream Management: Developed the DiscordDraftStream class to manage the full lifecycle of draft messages (send, edit, flush, clear), incorporating throttling (~1.2s between edits) to adhere to Discord's rate limits.
  • Typing Control: Created the DiscordTypingController class to handle the periodic sending and graceful stopping of the typing indicator.
  • Integration into Message Processing: Integrated the new streaming and typing logic by overriding the _run_process_loop method within the DiscordChannel.
  • Automatic Message Splitting: Included auto-splitting functionality for messages that exceed Discord's 2000-character limit during streaming.
  • Comprehensive Unit Tests: Added robust unit tests covering the new streaming classes, configuration wiring, and environment variable handling.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/copaw/app/channels/discord_/channel.py
    • Imported time and Literal for new functionality.
    • Defined constants for typing interval, timeout, draft placeholder, and throttle.
    • Added DiscordTypingController class to manage periodic trigger_typing calls.
    • Added DiscordDraftStream class for edit-based message streaming, including lazy sending, throttling, and content truncation.
    • Modified DiscordChannel.__init__ to accept a streaming_mode parameter.
    • Updated DiscordChannel.from_config to retrieve streaming_mode from configuration.
    • Overrode _run_process_loop to implement the new streaming and typing indicator logic, including message splitting and fallback behavior.
    • Added _extract_text_from_event helper method to process message events.
  • src/copaw/config/config.py
    • Added streaming_mode: Literal["off", "partial"] = "off" to DiscordConfig.
  • tests/unit/channels/test_discord_streaming.py
    • Added a new test file for Discord streaming features.
    • Included helper function _make_channel for mocking Discord messageables.
    • Added TestDiscordConfig to verify streaming_mode default, acceptance of 'partial', and validation.
    • Added TestDiscordTypingController to test start, stop, and restart prevention.
    • Added TestDiscordDraftStream to test placeholder sending, content editing, empty buffer deletion, and truncation.
    • Added TestFromEnvStreamingMode to verify streaming_mode wiring from environment variables.
Activity
  • The author provided a detailed summary of the changes and their purpose.
  • Security considerations were explicitly addressed, confirming no changes to authentication or credential handling.
  • The change type was marked as a 'New feature (non-breaking)'.
  • Detailed testing instructions were provided, including steps to verify both 'partial' streaming and 'off' (legacy) behavior.
  • The author confirmed that pre-commit checks passed, manual testing with a live Discord bot was performed, default configuration preserves existing behavior, and error paths clear draft messages cleanly.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Generative AI Prohibited Use Policy, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces an excellent feature: streaming responses and a typing indicator for the Discord channel. The implementation is well-structured, introducing DiscordTypingController and DiscordDraftStream to manage the new asynchronous logic, and includes a good set of unit tests. However, I've identified a critical issue where the new streaming_mode is not configurable via environment variables due to an omission in the from_env method. Additionally, I've found a couple of bugs in the new tests and have some suggestions for minor improvements to enhance consistency and readability in the implementation.

Comment on lines +183 to +214
class TestFromEnvStreamingMode:
def test_default_streaming_mode_from_env_is_off(self, monkeypatch):
"""DISCORD_STREAMING_MODE not set -> off."""
monkeypatch.delenv("DISCORD_STREAMING_MODE", raising=False)
# We don't need a real process handler for this test
from copaw.app.channels.discord_.channel import DiscordChannel

with patch.object(DiscordChannel, "__init__", return_value=None):
channel = DiscordChannel.__new__(DiscordChannel)
# Simulate from_env calling cls(**kwargs) with streaming_mode
import os

raw = os.getenv("DISCORD_STREAMING_MODE", "off").strip().lower()
mode = "partial" if raw == "partial" else "off"
assert mode == "off"

def test_partial_streaming_mode_from_env(self, monkeypatch):
"""DISCORD_STREAMING_MODE=partial -> partial."""
monkeypatch.setenv("DISCORD_STREAMING_MODE", "partial")
import os

raw = os.getenv("DISCORD_STREAMING_MODE", "off").strip().lower()
mode = "partial" if raw == "partial" else "off"
assert mode == "partial"

def test_unknown_streaming_mode_from_env_defaults_to_off(self, monkeypatch):
monkeypatch.setenv("DISCORD_STREAMING_MODE", "full")
import os

raw = os.getenv("DISCORD_STREAMING_MODE", "off").strip().lower()
mode = "partial" if raw == "partial" else "off"
assert mode == "off"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

These tests for from_env are testing logic that is not actually implemented in DiscordChannel.from_env. The streaming_mode parameter is missing from the cls(...) call in from_env, which means this feature cannot be configured via environment variables. This is a critical omission.

Please add the logic to parse the DISCORD_STREAMING_MODE environment variable in DiscordChannel.from_env.

draft = DiscordDraftStream(channel)
await draft.send_placeholder()
await draft.clear()
draft._draft_msg is None or None # after clear, ref is gone
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

This line is a statement, not an assertion. It doesn't verify that _draft_msg is set to None. This is likely a typo and should be an assert statement to make the test effective.

Suggested change
draft._draft_msg is None or None # after clear, ref is gone
assert draft._draft_msg is None # after clear, ref is gone

Comment on lines +71 to +84
deadline = asyncio.get_event_loop().time() + _TYPING_TIMEOUT_S
try:
while not self._stopped:
try:
await channel.trigger_typing()
except Exception:
logger.debug(
"discord: trigger_typing failed",
exc_info=True,
)
await asyncio.sleep(_TYPING_INTERVAL_S)
if asyncio.get_event_loop().time() >= deadline:
logger.debug("discord: typing loop timed out")
break
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The _typing_loop uses asyncio.get_event_loop().time() to manage its timeout. It's recommended to use time.monotonic() for consistency with DiscordDraftStream and to align with modern Python asyncio practices, as get_event_loop() is being phased out.

Using time.monotonic() is more explicit for measuring time durations and is not affected by system time changes.

Suggested change
deadline = asyncio.get_event_loop().time() + _TYPING_TIMEOUT_S
try:
while not self._stopped:
try:
await channel.trigger_typing()
except Exception:
logger.debug(
"discord: trigger_typing failed",
exc_info=True,
)
await asyncio.sleep(_TYPING_INTERVAL_S)
if asyncio.get_event_loop().time() >= deadline:
logger.debug("discord: typing loop timed out")
break
deadline = time.monotonic() + _TYPING_TIMEOUT_S
try:
while not self._stopped:
try:
await channel.trigger_typing()
except Exception:
logger.debug(
"discord: trigger_typing failed",
exc_info=True,
)
await asyncio.sleep(_TYPING_INTERVAL_S)
if time.monotonic() >= deadline:
logger.debug("discord: typing loop timed out")
break

Comment on lines +781 to +784
draft = DiscordDraftStream(
target,
max_len=getattr(self, "_DISCORD_MAX_LEN", 2000),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The expression getattr(self, "_DISCORD_MAX_LEN", 2000) is used multiple times within this method. Since _DISCORD_MAX_LEN is a class attribute, it's safe to access it directly via self._DISCORD_MAX_LEN. To improve readability and avoid repetition, consider storing this value in a local variable at the beginning of the method (e.g., max_len = self._DISCORD_MAX_LEN) and reusing it throughout.

max_len=getattr(self, "_DISCORD_MAX_LEN", 2000),
)
last_response = None
from agentscope_runtime.engine.schemas.agent_schemas import RunStatus
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

This local import of RunStatus should be moved to the top of the file with the other imports. This improves code readability and follows standard Python conventions. Local imports are generally used to avoid circular dependencies, which doesn't seem to be the case here.

Comment on lines +99 to +107
async def test_stopped_flag_prevents_restart(self):
from copaw.app.channels.discord_.channel import DiscordTypingController

channel = _make_channel()
ctrl = DiscordTypingController()
ctrl.start(channel)
ctrl.stop()
# After stop, _stopped should be True
assert ctrl._stopped is True
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

This test's name is test_stopped_flag_prevents_restart, but it only checks that the _stopped flag is set to True. It doesn't verify that the controller cannot be restarted. To make the test more robust and align with its name, it should attempt to call start() again after stop() and assert that no new task is created.

Suggested change
async def test_stopped_flag_prevents_restart(self):
from copaw.app.channels.discord_.channel import DiscordTypingController
channel = _make_channel()
ctrl = DiscordTypingController()
ctrl.start(channel)
ctrl.stop()
# After stop, _stopped should be True
assert ctrl._stopped is True
async def test_stopped_flag_prevents_restart(self):
from copaw.app.channels.discord_.channel import DiscordTypingController
channel = _make_channel()
ctrl = DiscordTypingController()
ctrl.start(channel)
await asyncio.sleep(0.01) # allow task to start
assert ctrl._task is not None
ctrl.stop()
assert ctrl._stopped is True
assert ctrl._task is None
# Attempt to restart and verify it doesn't create a new task
ctrl.start(channel)
assert ctrl._task is None

@Chiytako Chiytako requested a deployment to maintainer-approved March 12, 2026 19:02 — with GitHub Actions Waiting
@Chiytako Chiytako force-pushed the feat/discord-streaming branch from 4ec6406 to f228c51 Compare March 12, 2026 19:17
@Chiytako Chiytako requested a deployment to maintainer-approved March 12, 2026 19:17 — with GitHub Actions Waiting
@Chiytako Chiytako marked this pull request as draft March 12, 2026 19:52
@Chiytako Chiytako closed this Mar 12, 2026
@Chiytako Chiytako deleted the feat/discord-streaming branch March 12, 2026 20:03
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.

[Feature]: Discord channel: streaming responses via message edit + typing indicator

1 participant