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}