Skip to main content

weixin_agent/messaging/
outbound_run.rs

1//! Per-run outbound handle: `run_id` propagation and tool-call progress.
2
3use std::path::Path;
4use std::sync::Arc;
5
6use crate::error::Result;
7use crate::messaging::inbound::SendResult;
8use crate::messaging::sender::MessageSender;
9use crate::types::{
10    MessageItem, MessageItemType, ToolCallResultItem, ToolCallStartItem, ToolCallStatus,
11};
12use crate::util::{now_ms_i64, random::generate_run_id};
13
14/// A scoped handle for one logical outbound run.
15///
16/// All messages sent through this handle carry the same `run_id`, which lets the
17/// peer group them as one run. The run boundary is defined by the caller — this
18/// SDK never infers it.
19///
20/// Obtain one from [`crate::MessageContext::run`] or [`crate::WeixinClient::run`].
21///
22/// # Ordering
23///
24/// Each send is one HTTP request. Awaiting calls in sequence guarantees the peer
25/// observes them in that order. If you spawn sends concurrently, ordering is
26/// yours to manage.
27///
28/// # Observed behaviour
29///
30/// Verified against the production API on 2026-08-13: the server accepts
31/// tool-call progress items (HTTP 200) but answers with an empty body and
32/// allocates no `message_id`, whereas any message carrying a text item does get
33/// one — the server does not treat progress items as conversation messages, and
34/// the `WeChat` client does not render them. Five wire variants were tried
35/// (`GENERATING` state, progress merged into a text `item_list`, added
36/// `msg_id`/`update_time_ms`, dropped `run_id`/`context_token`) with no change,
37/// and `getConfig` exposes no capability flag. This reads as a capability iLink
38/// has reserved but not yet enabled.
39///
40/// `run_id` was likewise not observed to affect client-side presentation; treat
41/// it as a server-side correlation field. The value of both features today is
42/// protocol readiness, not a user-visible progress display.
43///
44/// # Example
45///
46/// ```rust,no_run
47/// # use weixin_agent::{MessageContext, Result, ToolCallStatus};
48/// # async fn demo(ctx: &MessageContext) -> Result<()> {
49/// let run = ctx.run();
50/// run.tool_call_start("bash", Some("call-1")).await?;
51/// // ... execute the tool ...
52/// run.tool_call_result("bash", Some("call-1"), ToolCallStatus::Completed)
53///     .await?;
54/// run.send_text("done").await?;
55/// # Ok(())
56/// # }
57/// ```
58pub struct OutboundRun {
59    sender: Arc<MessageSender>,
60    to: String,
61    context_token: Option<String>,
62    run_id: String,
63}
64
65impl OutboundRun {
66    /// Create a run with a freshly generated run ID.
67    pub(crate) fn new(sender: Arc<MessageSender>, to: &str, context_token: Option<&str>) -> Self {
68        Self {
69            sender,
70            to: to.to_owned(),
71            context_token: context_token.map(String::from),
72            run_id: generate_run_id(),
73        }
74    }
75
76    /// Override the auto-generated run ID (e.g. to reuse a caller-side run identifier).
77    #[must_use]
78    pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
79        self.run_id = run_id.into();
80        self
81    }
82
83    /// The run ID carried by every message sent through this handle.
84    pub fn run_id(&self) -> &str {
85        &self.run_id
86    }
87
88    /// The recipient of this run.
89    pub fn to(&self) -> &str {
90        &self.to
91    }
92
93    /// Send text (markdown filter applies per config, same as `reply_text`).
94    pub async fn send_text(&self, text: &str) -> Result<SendResult> {
95        self.sender
96            .send_text(
97                &self.to,
98                text,
99                self.context_token.as_deref(),
100                Some(&self.run_id),
101            )
102            .await
103    }
104
105    /// Upload and send a media file.
106    pub async fn send_media(&self, file_path: &Path) -> Result<SendResult> {
107        self.sender
108            .send_media(
109                &self.to,
110                file_path,
111                self.context_token.as_deref(),
112                Some(&self.run_id),
113            )
114            .await
115    }
116
117    /// Announce that a tool call started.
118    ///
119    /// `tool_call_id` pairs this event with the matching
120    /// [`Self::tool_call_result`]; provide one whenever the peer may see
121    /// overlapping calls.
122    pub async fn tool_call_start(
123        &self,
124        tool_name: &str,
125        tool_call_id: Option<&str>,
126    ) -> Result<SendResult> {
127        self.sender
128            .send_item(
129                &self.to,
130                build_tool_call_start_item(tool_name, tool_call_id),
131                self.context_token.as_deref(),
132                Some(&self.run_id),
133            )
134            .await
135    }
136
137    /// Announce that a tool call finished.
138    pub async fn tool_call_result(
139        &self,
140        tool_name: &str,
141        tool_call_id: Option<&str>,
142        status: ToolCallStatus,
143    ) -> Result<SendResult> {
144        self.sender
145            .send_item(
146                &self.to,
147                build_tool_call_result_item(tool_name, tool_call_id, status),
148                self.context_token.as_deref(),
149                Some(&self.run_id),
150            )
151            .await
152    }
153}
154
155/// Build a tool-call start item (type 11, not yet completed).
156///
157/// The markdown filter is deliberately not applied: `tool_name` is an identifier,
158/// not display prose.
159pub(crate) fn build_tool_call_start_item(
160    tool_name: &str,
161    tool_call_id: Option<&str>,
162) -> MessageItem {
163    MessageItem {
164        item_type: Some(MessageItemType::ToolCallStart),
165        create_time_ms: Some(now_ms_i64()),
166        is_completed: Some(false),
167        tool_call_start_item: Some(ToolCallStartItem {
168            tool_name: Some(tool_name.to_owned()),
169            tool_call_id: tool_call_id.map(String::from),
170        }),
171        ..Default::default()
172    }
173}
174
175/// Build a tool-call result item (type 12, completed).
176pub(crate) fn build_tool_call_result_item(
177    tool_name: &str,
178    tool_call_id: Option<&str>,
179    status: ToolCallStatus,
180) -> MessageItem {
181    MessageItem {
182        item_type: Some(MessageItemType::ToolCallResult),
183        create_time_ms: Some(now_ms_i64()),
184        is_completed: Some(true),
185        tool_call_result_item: Some(ToolCallResultItem {
186            tool_name: Some(tool_name.to_owned()),
187            tool_call_id: tool_call_id.map(String::from),
188            status: Some(status.as_str().to_owned()),
189        }),
190        ..Default::default()
191    }
192}