sendMessage method

Future<void> sendMessage(
  1. ChatController controller,
  2. String text, {
  3. List<Attachment>? attachments,
})

Drives controller's manual streaming API directly, so tool calls surface as ToolCalls instead of being dropped.

Mirrors what ChatController.send does for plain text: adds a user message, opens a streaming assistant message, and finalizes it on completion or failure.

Note: ChatController.stop cancels its own internal subscription, but this method drives the controller with a plain await for rather than through that subscription, so stop() cannot cancel the underlying HTTP request directly. It still stops new tokens from reaching the UI — each loop iteration checks whether this message is still the controller's active stream and returns early once stop() has cleared it — but the request keeps draining server-side until it completes; its remaining output is simply discarded.

Implementation

Future<void> sendMessage(
  ChatController controller,
  String text, {
  List<Attachment>? attachments,
}) async {
  final userMessage = ChatMessage.user(text, attachments: attachments);
  controller.addMessage(userMessage);
  // Snapshot before beginAssistantMessage() adds the empty streaming
  // placeholder -- that placeholder must not be sent as a conversation turn.
  final conversation = controller.messages;
  final assistantId = controller.beginAssistantMessage();

  try {
    await for (final event in streamEvents(conversation)) {
      if (controller.streamingMessageId != assistantId) return;
      switch (event) {
        case TextDelta(text: final chunk):
          controller.appendToken(assistantId, chunk);
        case ToolCallEvent(:final call):
          controller.upsertToolCall(assistantId, call);
      }
    }
    if (controller.streamingMessageId == assistantId) {
      controller.completeMessage(assistantId);
    }
  } catch (error) {
    if (controller.streamingMessageId == assistantId) {
      controller.failMessage(assistantId, error.toString());
    }
  }
}