agent-message 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,857 +0,0 @@
1
- use std::collections::HashSet;
2
- use std::time::Duration;
3
-
4
- use anyhow::{Context, Result, anyhow};
5
- use rand::Rng;
6
- use serde_json::Map;
7
- use serde_json::Value;
8
- use serde_json::json;
9
- use uuid::Uuid;
10
-
11
- use crate::Config;
12
- use crate::SandboxArg;
13
- use crate::agent_message::{AgentMessageClient, Message};
14
- use crate::codex::{CodexAppServer, IncomingMessage};
15
- use crate::render::{approval_spec, report_spec};
16
-
17
- const REQUEST_SUFFIX: &str = r#"
18
-
19
- Operational requirements from the codex-message wrapper:
20
- - The final result will be forwarded to the user through agent-message.
21
- - Prefer a visually readable report format suitable for an agent-message json-render response.
22
- - If you need approval or clarification, ask clearly and briefly so the wrapper can relay it.
23
- "#;
24
-
25
- pub(crate) struct App {
26
- config: Config,
27
- }
28
-
29
- impl App {
30
- pub(crate) fn new(config: Config) -> Self {
31
- Self { config }
32
- }
33
-
34
- pub(crate) async fn run(self) -> Result<()> {
35
- let mut runtime = Runtime::bootstrap(self.config).await?;
36
- runtime.run_loop().await
37
- }
38
- }
39
-
40
- struct Runtime {
41
- config: Config,
42
- chat_id: String,
43
- to_username: String,
44
- agent_client: AgentMessageClient,
45
- seen_message_ids: HashSet<String>,
46
- codex: CodexAppServer,
47
- thread_id: String,
48
- }
49
-
50
- impl Runtime {
51
- async fn bootstrap(config: Config) -> Result<Self> {
52
- let chat_id = new_chat_id();
53
- let username = format!("agent-{chat_id}");
54
- let pin = new_pin();
55
- let to_username = config.to_username.clone();
56
- let agent_client = AgentMessageClient::new(std::path::PathBuf::from("agent-message"));
57
-
58
- register_agent_account(&agent_client, &username, &pin).await?;
59
- println!(
60
- "registered agent profile: {username} (chat_id: {chat_id})"
61
- );
62
-
63
- let startup_text = format!(
64
- "codex-message session started\nchat_id: {chat_id}\nusername: {username}\npin: {pin}\n\nReply in this DM to run Codex."
65
- );
66
- let startup_message_id = agent_client
67
- .send_text_message(&to_username, &startup_text)
68
- .await
69
- .context("send startup message")?;
70
- println!(
71
- "startup message sent to {to_username}: {startup_message_id}"
72
- );
73
-
74
- let codex = CodexAppServer::start(&config.codex_bin, &config.cwd)
75
- .await
76
- .context("start codex app-server")?;
77
- codex
78
- .initialize()
79
- .await
80
- .context("initialize codex app-server")?;
81
- let thread_id = start_thread(&codex, &config).await?;
82
- println!("codex app-server ready (thread_id: {thread_id})");
83
-
84
- Ok(Self {
85
- config,
86
- chat_id,
87
- to_username,
88
- agent_client,
89
- seen_message_ids: HashSet::new(),
90
- codex,
91
- thread_id,
92
- })
93
- }
94
-
95
- async fn run_loop(&mut self) -> Result<()> {
96
- loop {
97
- tokio::select! {
98
- _ = tokio::signal::ctrl_c() => {
99
- self.codex.shutdown().await?;
100
- return Ok(());
101
- }
102
- next = self.next_target_message() => {
103
- let message = next?;
104
- let Some(request) = extract_request_text(&message) else {
105
- continue;
106
- };
107
-
108
- match self.run_turn(&request).await {
109
- Ok(outcome) => {
110
- let spec = report_spec(
111
- match outcome.status.as_str() {
112
- "completed" => "Completed",
113
- "interrupted" => "Interrupted",
114
- _ => "Failed",
115
- },
116
- "Codex report",
117
- &[
118
- format!("Chat ID: {}", self.chat_id),
119
- format!("Request: {}", request.trim()),
120
- format!("Status: {}", outcome.status),
121
- ],
122
- Some(&outcome.report_body()),
123
- );
124
- self.agent_client
125
- .send_json_render_message(&self.to_username, spec)
126
- .await
127
- .context("send turn report")?;
128
- }
129
- Err(error) => {
130
- let spec = report_spec(
131
- "Error",
132
- "Codex request failed",
133
- &[
134
- format!("Chat ID: {}", self.chat_id),
135
- format!("Request: {}", request.trim()),
136
- ],
137
- Some(&error.to_string()),
138
- );
139
- self.agent_client
140
- .send_json_render_message(&self.to_username, spec)
141
- .await
142
- .context("send failure report")?;
143
- }
144
- }
145
- }
146
- }
147
- }
148
- }
149
-
150
- async fn next_target_message(&mut self) -> Result<Message> {
151
- loop {
152
- let messages = self
153
- .agent_client
154
- .read_messages(&self.to_username, 20)
155
- .await
156
- .context("poll messages from agent-message")?;
157
-
158
- for message in messages.into_iter().rev() {
159
- if !self.seen_message_ids.insert(message.id.clone()) {
160
- continue;
161
- }
162
- if !message
163
- .sender_username
164
- .eq_ignore_ascii_case(&self.to_username)
165
- {
166
- continue;
167
- }
168
- if extract_request_text(&message).is_none() {
169
- continue;
170
- }
171
- return Ok(message);
172
- }
173
-
174
- tokio::time::sleep(Duration::from_secs(2)).await;
175
- }
176
- }
177
-
178
- async fn run_turn(&mut self, request: &str) -> Result<TurnOutcome> {
179
- let composed_request = format!("{}\n{}", request.trim(), REQUEST_SUFFIX);
180
- let turn_params =
181
- build_turn_start_params(&self.config, &self.thread_id, &composed_request)?;
182
- let response = self
183
- .codex
184
- .request("turn/start", turn_params)
185
- .await
186
- .context("start codex turn")?;
187
- let turn_id = response
188
- .get("turn")
189
- .and_then(|turn| turn.get("id"))
190
- .and_then(Value::as_str)
191
- .map(ToOwned::to_owned)
192
- .ok_or_else(|| anyhow!("turn/start response missing turn.id"))?;
193
-
194
- let mut agent_text = String::new();
195
-
196
- let (turn_status, turn_error) = loop {
197
- match self.codex.next_event().await? {
198
- IncomingMessage::Notification { method, params } => match method.as_str() {
199
- "item/agentMessage/delta" => {
200
- if params.get("turnId").and_then(Value::as_str) == Some(turn_id.as_str()) {
201
- if let Some(delta) = params.get("delta").and_then(Value::as_str) {
202
- agent_text.push_str(delta);
203
- }
204
- }
205
- }
206
- "turn/completed" => {
207
- if params
208
- .get("turn")
209
- .and_then(|turn| turn.get("id"))
210
- .and_then(Value::as_str)
211
- != Some(turn_id.as_str())
212
- {
213
- continue;
214
- }
215
-
216
- let status = params
217
- .get("turn")
218
- .and_then(|turn| turn.get("status"))
219
- .and_then(Value::as_str)
220
- .map(ToOwned::to_owned)
221
- .unwrap_or_else(|| "unknown".to_string());
222
- let error = params
223
- .get("turn")
224
- .and_then(|turn| turn.get("error"))
225
- .and_then(|error| error.get("message"))
226
- .and_then(Value::as_str)
227
- .map(ToOwned::to_owned);
228
- break (status, error);
229
- }
230
- _ => {}
231
- },
232
- IncomingMessage::Request { method, id, params } => {
233
- self.handle_server_request(method.as_str(), id, params)
234
- .await?;
235
- }
236
- }
237
- };
238
-
239
- Ok(TurnOutcome {
240
- status: turn_status,
241
- agent_text: agent_text.trim().to_string(),
242
- error_text: turn_error,
243
- })
244
- }
245
-
246
- async fn handle_server_request(
247
- &mut self,
248
- method: &str,
249
- id: Value,
250
- params: Value,
251
- ) -> Result<()> {
252
- match method {
253
- "item/commandExecution/requestApproval" => {
254
- let details = summarize_command_approval(&params);
255
- let spec = approval_spec(
256
- "Approval Needed",
257
- "Command approval requested",
258
- &details,
259
- "approve | session | deny | cancel",
260
- );
261
- self.agent_client
262
- .send_json_render_message(&self.to_username, spec)
263
- .await
264
- .context("send command approval request")?;
265
-
266
- let decision = loop {
267
- let reply = self.next_target_message().await?;
268
- let Some(text) = extract_request_text(&reply) else {
269
- continue;
270
- };
271
- if let Some(decision) = parse_command_decision(&text) {
272
- break decision;
273
- }
274
- self.agent_client
275
- .send_text_message(
276
- &self.to_username,
277
- "Reply with one of: approve, session, deny, cancel.",
278
- )
279
- .await
280
- .context("send command approval clarification")?;
281
- };
282
-
283
- self.codex
284
- .respond(id, json!({ "decision": decision }))
285
- .await
286
- .context("respond to command approval")?;
287
- }
288
- "item/fileChange/requestApproval" => {
289
- let details = summarize_file_approval(&params);
290
- let spec = approval_spec(
291
- "Approval Needed",
292
- "File change approval requested",
293
- &details,
294
- "approve | session | deny | cancel",
295
- );
296
- self.agent_client
297
- .send_json_render_message(&self.to_username, spec)
298
- .await
299
- .context("send file approval request")?;
300
-
301
- let decision = loop {
302
- let reply = self.next_target_message().await?;
303
- let Some(text) = extract_request_text(&reply) else {
304
- continue;
305
- };
306
- if let Some(decision) = parse_file_decision(&text) {
307
- break decision;
308
- }
309
- self.agent_client
310
- .send_text_message(
311
- &self.to_username,
312
- "Reply with one of: approve, session, deny, cancel.",
313
- )
314
- .await
315
- .context("send file approval clarification")?;
316
- };
317
-
318
- self.codex
319
- .respond(id, json!({ "decision": decision }))
320
- .await
321
- .context("respond to file approval")?;
322
- }
323
- "item/permissions/requestApproval" => {
324
- let details = summarize_permissions_request(&params);
325
- let requested_permissions = params
326
- .get("permissions")
327
- .cloned()
328
- .unwrap_or_else(|| json!({}));
329
- let spec = approval_spec(
330
- "Permission Needed",
331
- "Additional permissions requested",
332
- &details,
333
- "allow | allow session | deny",
334
- );
335
- self.agent_client
336
- .send_json_render_message(&self.to_username, spec)
337
- .await
338
- .context("send permissions request")?;
339
-
340
- let response = loop {
341
- let reply = self.next_target_message().await?;
342
- let Some(text) = extract_request_text(&reply) else {
343
- continue;
344
- };
345
- if let Some(response) = parse_permission_response(&text, &requested_permissions)
346
- {
347
- break response;
348
- }
349
- self.agent_client
350
- .send_text_message(
351
- &self.to_username,
352
- "Reply with one of: allow, allow session, deny.",
353
- )
354
- .await
355
- .context("send permissions clarification")?;
356
- };
357
-
358
- self.codex
359
- .respond(id, response)
360
- .await
361
- .context("respond to permissions request")?;
362
- }
363
- "item/tool/requestUserInput" => {
364
- let questions = params
365
- .get("questions")
366
- .and_then(Value::as_array)
367
- .cloned()
368
- .unwrap_or_default();
369
- let details = summarize_tool_questions(&questions);
370
- let spec = approval_spec(
371
- "Input Needed",
372
- "Codex requested user input",
373
- &details,
374
- "JSON object keyed by question id, or plain text if there is only one question",
375
- );
376
- self.agent_client
377
- .send_json_render_message(&self.to_username, spec)
378
- .await
379
- .context("send request_user_input prompt")?;
380
-
381
- let response = loop {
382
- let reply = self.next_target_message().await?;
383
- let Some(text) = extract_request_text(&reply) else {
384
- continue;
385
- };
386
- if let Some(response) = parse_tool_user_input_response(&text, &questions) {
387
- break response;
388
- }
389
- self.agent_client
390
- .send_text_message(
391
- &self.to_username,
392
- "Reply with JSON like {\"question_id\":\"answer\"}. For a single question, plain text also works.",
393
- )
394
- .await
395
- .context("send request_user_input clarification")?;
396
- };
397
-
398
- self.codex
399
- .respond(id, response)
400
- .await
401
- .context("respond to request_user_input")?;
402
- }
403
- "mcpServer/elicitation/request" => {
404
- let details = summarize_mcp_elicitation(&params);
405
- let spec = approval_spec(
406
- "MCP Input",
407
- "An MCP server requested interaction",
408
- &details,
409
- "accept | decline | cancel, optionally followed by JSON content",
410
- );
411
- self.agent_client
412
- .send_json_render_message(&self.to_username, spec)
413
- .await
414
- .context("send MCP elicitation prompt")?;
415
-
416
- let response = loop {
417
- let reply = self.next_target_message().await?;
418
- let Some(text) = extract_request_text(&reply) else {
419
- continue;
420
- };
421
- if let Some(response) = parse_mcp_elicitation_response(&text) {
422
- break response;
423
- }
424
- self.agent_client
425
- .send_text_message(
426
- &self.to_username,
427
- "Reply with `accept`, `decline`, or `cancel`. You can append JSON after `accept` if the MCP server needs content.",
428
- )
429
- .await
430
- .context("send MCP clarification")?;
431
- };
432
-
433
- self.codex
434
- .respond(id, response)
435
- .await
436
- .context("respond to MCP elicitation")?;
437
- }
438
- other => {
439
- let spec = report_spec(
440
- "Unsupported",
441
- "Unhandled Codex interaction",
442
- &[format!("Method: {other}")],
443
- Some("codex-message does not implement this server request type yet."),
444
- );
445
- self.agent_client
446
- .send_json_render_message(&self.to_username, spec)
447
- .await
448
- .context("send unsupported interaction notice")?;
449
- self.codex
450
- .respond(id, json!({}))
451
- .await
452
- .context("respond to unsupported request with empty object")?;
453
- }
454
- }
455
-
456
- Ok(())
457
- }
458
- }
459
-
460
- #[derive(Debug)]
461
- struct TurnOutcome {
462
- status: String,
463
- agent_text: String,
464
- error_text: Option<String>,
465
- }
466
-
467
- impl TurnOutcome {
468
- fn report_body(&self) -> String {
469
- match (&self.agent_text.is_empty(), &self.error_text) {
470
- (false, Some(error)) => format!("{}\n\nError: {}", self.agent_text, error),
471
- (false, None) => self.agent_text.clone(),
472
- (true, Some(error)) => error.clone(),
473
- (true, None) => "Codex completed without an assistant message.".to_string(),
474
- }
475
- }
476
- }
477
-
478
- async fn register_agent_account(
479
- client: &AgentMessageClient,
480
- username: &str,
481
- pin: &str,
482
- ) -> Result<()> {
483
- client
484
- .register(username, pin)
485
- .await
486
- .context("register agent-message account")
487
- }
488
-
489
- async fn start_thread(codex: &CodexAppServer, config: &Config) -> Result<String> {
490
- let mut params = Map::new();
491
- params.insert(
492
- "cwd".to_string(),
493
- Value::String(config.cwd.to_string_lossy().into_owned()),
494
- );
495
- if let Some(model) = &config.model {
496
- params.insert("model".to_string(), Value::String(model.clone()));
497
- }
498
-
499
- let response = codex
500
- .request("thread/start", Value::Object(params))
501
- .await
502
- .context("start codex thread")?;
503
- response
504
- .get("thread")
505
- .and_then(|thread| thread.get("id"))
506
- .and_then(Value::as_str)
507
- .map(ToOwned::to_owned)
508
- .ok_or_else(|| anyhow!("thread/start response missing thread.id"))
509
- }
510
-
511
- fn build_turn_start_params(config: &Config, thread_id: &str, text: &str) -> Result<Value> {
512
- let mut params = Map::new();
513
- params.insert("threadId".to_string(), Value::String(thread_id.to_string()));
514
- params.insert(
515
- "input".to_string(),
516
- Value::Array(vec![json!({
517
- "type": "text",
518
- "text": text,
519
- })]),
520
- );
521
- params.insert(
522
- "cwd".to_string(),
523
- Value::String(config.cwd.to_string_lossy().into_owned()),
524
- );
525
- if let Some(model) = &config.model {
526
- params.insert("model".to_string(), Value::String(model.clone()));
527
- }
528
- if let Some(policy) = &config.approval_policy {
529
- params.insert("approvalPolicy".to_string(), Value::String(policy.clone()));
530
- }
531
- params.insert("sandboxPolicy".to_string(), sandbox_policy(config));
532
- Ok(Value::Object(params))
533
- }
534
-
535
- fn sandbox_policy(config: &Config) -> Value {
536
- match config.sandbox {
537
- SandboxArg::ReadOnly => json!({
538
- "type": "readOnly",
539
- "networkAccess": config.network_access,
540
- }),
541
- SandboxArg::WorkspaceWrite => json!({
542
- "type": "workspaceWrite",
543
- "writableRoots": [config.cwd.to_string_lossy()],
544
- "networkAccess": config.network_access,
545
- }),
546
- SandboxArg::DangerFullAccess => json!({
547
- "type": "dangerFullAccess",
548
- }),
549
- }
550
- }
551
-
552
- fn extract_request_text(message: &Message) -> Option<String> {
553
- let trimmed = message.text.trim();
554
- if trimmed.is_empty() {
555
- return None;
556
- }
557
- if trimmed == "[json-render]" || trimmed == "deleted message" {
558
- return None;
559
- }
560
- Some(trimmed.to_string())
561
- }
562
-
563
- fn summarize_command_approval(params: &Value) -> Vec<String> {
564
- let mut details = Vec::new();
565
- if let Some(reason) = params.get("reason").and_then(Value::as_str) {
566
- details.push(format!("Reason: {reason}"));
567
- }
568
- if let Some(command) = params.get("command").and_then(Value::as_str) {
569
- details.push(format!("Command: {command}"));
570
- }
571
- if let Some(cwd) = params.get("cwd").and_then(Value::as_str) {
572
- details.push(format!("CWD: {cwd}"));
573
- }
574
- if let Some(permissions) = params.get("additionalPermissions") {
575
- details.push(format!(
576
- "Additional permissions: {}",
577
- serde_json::to_string_pretty(permissions).unwrap_or_else(|_| permissions.to_string())
578
- ));
579
- }
580
- if details.is_empty() {
581
- details.push("Codex requested command execution approval.".to_string());
582
- }
583
- details
584
- }
585
-
586
- fn summarize_file_approval(params: &Value) -> Vec<String> {
587
- let mut details = Vec::new();
588
- if let Some(reason) = params.get("reason").and_then(Value::as_str) {
589
- details.push(format!("Reason: {reason}"));
590
- }
591
- if let Some(root) = params.get("grantRoot").and_then(Value::as_str) {
592
- details.push(format!("Grant root: {root}"));
593
- }
594
- if details.is_empty() {
595
- details.push("Codex requested approval for file changes.".to_string());
596
- }
597
- details
598
- }
599
-
600
- fn summarize_permissions_request(params: &Value) -> Vec<String> {
601
- let mut details = Vec::new();
602
- if let Some(reason) = params.get("reason").and_then(Value::as_str) {
603
- details.push(format!("Reason: {reason}"));
604
- }
605
- if let Some(permissions) = params.get("permissions") {
606
- details.push(format!(
607
- "Requested permissions: {}",
608
- serde_json::to_string_pretty(permissions).unwrap_or_else(|_| permissions.to_string())
609
- ));
610
- }
611
- if details.is_empty() {
612
- details.push("Codex requested additional permissions.".to_string());
613
- }
614
- details
615
- }
616
-
617
- fn summarize_tool_questions(questions: &[Value]) -> Vec<String> {
618
- let mut details = Vec::new();
619
- for question in questions {
620
- let id = question
621
- .get("id")
622
- .and_then(Value::as_str)
623
- .unwrap_or("question");
624
- let text = question
625
- .get("question")
626
- .and_then(Value::as_str)
627
- .unwrap_or("No question text provided");
628
- details.push(format!("{id}: {text}"));
629
- if let Some(options) = question.get("options").and_then(Value::as_array) {
630
- let labels: Vec<String> = options
631
- .iter()
632
- .filter_map(|option| option.get("label").and_then(Value::as_str))
633
- .map(ToOwned::to_owned)
634
- .collect();
635
- if !labels.is_empty() {
636
- details.push(format!("Options for {id}: {}", labels.join(", ")));
637
- }
638
- }
639
- }
640
- if details.is_empty() {
641
- details.push("Codex requested additional user input.".to_string());
642
- }
643
- details
644
- }
645
-
646
- fn summarize_mcp_elicitation(params: &Value) -> Vec<String> {
647
- let mut details = Vec::new();
648
- if let Some(server_name) = params.get("serverName").and_then(Value::as_str) {
649
- details.push(format!("Server: {server_name}"));
650
- }
651
- if let Some(mode) = params.get("mode").and_then(Value::as_str) {
652
- details.push(format!("Mode: {mode}"));
653
- }
654
- if let Some(message) = params.get("message").and_then(Value::as_str) {
655
- details.push(format!("Message: {message}"));
656
- }
657
- if let Some(url) = params.get("url").and_then(Value::as_str) {
658
- details.push(format!("URL: {url}"));
659
- }
660
- if details.is_empty() {
661
- details.push("An MCP server requested interaction.".to_string());
662
- }
663
- details
664
- }
665
-
666
- fn parse_command_decision(text: &str) -> Option<Value> {
667
- let normalized = normalize_reply(text);
668
- if normalized.contains("cancel") || normalized.contains("abort") {
669
- return Some(json!("cancel"));
670
- }
671
- if normalized.contains("session") {
672
- return Some(json!("acceptForSession"));
673
- }
674
- if normalized.contains("deny") || normalized.contains("decline") || normalized == "no" {
675
- return Some(json!("decline"));
676
- }
677
- if normalized.contains("approve")
678
- || normalized.contains("accept")
679
- || normalized.contains("allow")
680
- || normalized == "yes"
681
- {
682
- return Some(json!("accept"));
683
- }
684
- None
685
- }
686
-
687
- fn parse_file_decision(text: &str) -> Option<Value> {
688
- parse_command_decision(text)
689
- }
690
-
691
- fn parse_permission_response(text: &str, requested_permissions: &Value) -> Option<Value> {
692
- let normalized = normalize_reply(text);
693
- if normalized.contains("deny") || normalized.contains("decline") || normalized == "no" {
694
- return Some(json!({
695
- "scope": "turn",
696
- "permissions": {},
697
- }));
698
- }
699
- if normalized.contains("allow")
700
- || normalized.contains("approve")
701
- || normalized.contains("accept")
702
- || normalized == "yes"
703
- {
704
- let scope = if normalized.contains("session") {
705
- "session"
706
- } else {
707
- "turn"
708
- };
709
- return Some(json!({
710
- "scope": scope,
711
- "permissions": requested_permissions,
712
- }));
713
- }
714
- None
715
- }
716
-
717
- fn parse_tool_user_input_response(text: &str, questions: &[Value]) -> Option<Value> {
718
- if questions.is_empty() {
719
- return Some(json!({ "answers": {} }));
720
- }
721
-
722
- if let Ok(value) = serde_json::from_str::<Value>(text) {
723
- let answers_object = value.get("answers").unwrap_or(&value);
724
- if let Some(answer_map) = answers_object.as_object() {
725
- let mut answers = Map::new();
726
- for question in questions {
727
- let id = question.get("id").and_then(Value::as_str)?;
728
- let answer_value = answer_map.get(id)?;
729
- let answers_array = match answer_value {
730
- Value::String(text) => vec![Value::String(text.clone())],
731
- Value::Array(values) => values.clone(),
732
- _ => return None,
733
- };
734
- answers.insert(id.to_string(), json!({ "answers": answers_array }));
735
- }
736
- return Some(json!({ "answers": answers }));
737
- }
738
- }
739
-
740
- if questions.len() == 1 {
741
- let id = questions[0].get("id").and_then(Value::as_str)?;
742
- let answer = text.trim();
743
- if answer.is_empty() {
744
- return None;
745
- }
746
- return Some(json!({
747
- "answers": {
748
- id: {
749
- "answers": [answer],
750
- }
751
- }
752
- }));
753
- }
754
-
755
- None
756
- }
757
-
758
- fn parse_mcp_elicitation_response(text: &str) -> Option<Value> {
759
- let trimmed = text.trim();
760
- if trimmed.is_empty() {
761
- return None;
762
- }
763
- let normalized = normalize_reply(trimmed);
764
- if normalized == "decline" || normalized == "deny" {
765
- return Some(json!({ "action": "decline", "content": Value::Null }));
766
- }
767
- if normalized == "cancel" || normalized == "abort" {
768
- return Some(json!({ "action": "cancel", "content": Value::Null }));
769
- }
770
- if normalized.starts_with("accept") {
771
- let rest = trimmed["accept".len()..].trim();
772
- let content = if rest.is_empty() {
773
- Value::Null
774
- } else if let Ok(json_value) = serde_json::from_str::<Value>(rest) {
775
- json_value
776
- } else {
777
- Value::String(rest.to_string())
778
- };
779
- return Some(json!({ "action": "accept", "content": content }));
780
- }
781
- None
782
- }
783
-
784
- fn normalize_reply(text: &str) -> String {
785
- text.trim().to_ascii_lowercase()
786
- }
787
-
788
- fn new_chat_id() -> String {
789
- Uuid::new_v4().simple().to_string()[..12].to_string()
790
- }
791
-
792
- fn new_pin() -> String {
793
- let mut rng = rand::rng();
794
- format!("{:06}", rng.random_range(0..=999_999))
795
- }
796
-
797
- #[cfg(test)]
798
- mod tests {
799
- use super::*;
800
-
801
- #[test]
802
- fn command_approval_reply_is_parsed() {
803
- assert_eq!(parse_command_decision("approve"), Some(json!("accept")));
804
- assert_eq!(
805
- parse_command_decision("allow session"),
806
- Some(json!("acceptForSession"))
807
- );
808
- assert_eq!(parse_command_decision("deny"), Some(json!("decline")));
809
- assert_eq!(parse_command_decision("cancel"), Some(json!("cancel")));
810
- }
811
-
812
- #[test]
813
- fn permission_reply_grants_requested_subset() {
814
- let requested = json!({
815
- "network": { "enabled": true },
816
- "fileSystem": { "write": ["/tmp/demo"] }
817
- });
818
- assert_eq!(
819
- parse_permission_response("allow session", &requested),
820
- Some(json!({
821
- "scope": "session",
822
- "permissions": requested,
823
- }))
824
- );
825
- }
826
-
827
- #[test]
828
- fn request_user_input_accepts_single_plain_text_reply() {
829
- let questions = vec![json!({
830
- "id": "workspace",
831
- "question": "Which workspace should I use?",
832
- })];
833
-
834
- assert_eq!(
835
- parse_tool_user_input_response("repo-a", &questions),
836
- Some(json!({
837
- "answers": {
838
- "workspace": {
839
- "answers": ["repo-a"]
840
- }
841
- }
842
- }))
843
- );
844
- }
845
-
846
- #[test]
847
- fn extract_request_text_filters_special_messages() {
848
- assert_eq!(
849
- extract_request_text(&Message {
850
- id: "m1".to_string(),
851
- sender_username: "jay".to_string(),
852
- text: "[json-render]".to_string(),
853
- }),
854
- None
855
- );
856
- }
857
- }