Skip to main content

mj_controller/hel_controller/
reviewer.rs

1//! Staging the second-opinion reviewer's profile onto a session's target.
2//!
3//! The reviewer runs a different configured profile in the primary session's
4//! own target. Its harness home is a fresh copy of that profile, placed inside
5//! the primary worker root, so the reviewer never reads or writes the
6//! primary's home and nothing outside the worker root has to be provisioned.
7//!
8//! Nothing here creates a session record, a target, a checkout, or any target
9//! lifecycle operation. Staging a reviewer is a file copy into a directory the
10//! session's worker already owns.
11
12use std::path::Path;
13
14use anyhow::{Context, Result, bail, ensure};
15
16use super::worker_binary::{bridge_launch, container_upload_ownership_args, stage_profile};
17use super::{Controller, execute_checked, scp_command_spec, ssh_command_spec};
18use hel::hel_targets::{self, CommandExecutor, CommandSpec, ProcessExecutor};
19use hel::hel_worker_launch::{
20    REVIEWER_DIR, ReviewMcpDelivery, ReviewMcpServer, ReviewerLaunchConfig,
21    reviewer_staging_profile_home,
22};
23
24/// Build the capability used by client-side chat views to stage reviewers.
25/// The blocking filesystem and target work remains implemented by the
26/// controller and is invoked from chat's supervised blocking task.
27pub fn reviewer_stager() -> mj_client::session::ReviewerStager {
28    mj_client::session::ReviewerStager::new(ControllerReviewerStager)
29}
30
31struct ControllerReviewerStager;
32
33impl mj_client::session::ReviewerStagerBackend for ControllerReviewerStager {
34    fn stage(
35        &self,
36        config: hel::hel_config::HelConfig,
37        session: hel::hel_state::SessionRecord,
38        profile_id: String,
39        generation: u64,
40    ) -> Result<ReviewerLaunchConfig> {
41        let session_id = session.id.clone();
42        let controller = Controller {
43            config,
44            state: hel::hel_state::HelState {
45                sessions: std::collections::BTreeMap::from([(session_id.clone(), session)]),
46                ..hel::hel_state::HelState::default()
47            },
48        };
49        controller.stage_reviewer_profile(&session_id, &profile_id, generation)
50    }
51}
52
53impl Controller {
54    /// Copy `profile_id`'s home into the session worker's reviewer directory
55    /// and describe how the worker should launch it.
56    ///
57    /// `generation` distinguishes reviewer lifetimes: bumping it tells the
58    /// worker to start a new conversation instead of reloading the last one.
59    pub fn stage_reviewer_profile(
60        &self,
61        session_id: &str,
62        profile_id: &str,
63        generation: u64,
64    ) -> Result<ReviewerLaunchConfig> {
65        self.stage_reviewer_profile_controlled(
66            session_id,
67            profile_id,
68            generation,
69            &[],
70            &ProcessExecutor,
71        )
72    }
73
74    /// Stage a reviewer that also gets `mcp_servers`, which is how a turn
75    /// review attaches its analyzer tools.
76    ///
77    /// `dispatch_tool` adds the review supervisor's own tool, which is this
78    /// worker's binary in another mode. Only the controller knows where that
79    /// binary and its socket sit on the target, so it is built here rather
80    /// than by the caller.
81    pub fn stage_reviewer_profile_with_mcp(
82        &self,
83        session_id: &str,
84        profile_id: &str,
85        generation: u64,
86        mcp_servers: &[ReviewMcpServer],
87        dispatch_tool: bool,
88    ) -> Result<ReviewerLaunchConfig> {
89        let mut servers = mcp_servers.to_vec();
90        if dispatch_tool {
91            let (_, worker_root) = self.worker_placement(session_id)?;
92            servers.push(review_dispatch_server(&worker_root));
93        }
94        self.stage_reviewer_profile_controlled(
95            session_id,
96            profile_id,
97            generation,
98            &servers,
99            &ProcessExecutor,
100        )
101    }
102
103    pub fn stage_reviewer_profile_controlled(
104        &self,
105        session_id: &str,
106        profile_id: &str,
107        generation: u64,
108        mcp_servers: &[ReviewMcpServer],
109        executor: &impl CommandExecutor,
110    ) -> Result<ReviewerLaunchConfig> {
111        let profile = self
112            .config
113            .profiles
114            .get(profile_id)
115            .with_context(|| format!("unknown profile {profile_id:?}"))?;
116        ensure!(
117            profile.enabled,
118            "reviewer profile {profile_id:?} is disabled"
119        );
120        if !profile.kind.supports_injected_mcp() {
121            bail!(
122                "Muse Code cannot be a reviewer: muse-acp does not accept the required MCP tools. Select another reviewer profile."
123            );
124        }
125        let session = self
126            .state
127            .sessions
128            .get(session_id)
129            .with_context(|| format!("unknown session {session_id}"))?;
130        let target = self
131            .config
132            .targets
133            .get(&session.target_template_id)
134            .context("session target template is missing")?;
135        let execution_policy = target.execution_policy();
136        let (backend, worker_root) = self.worker_placement(session_id)?;
137
138        let staging = tempfile::tempdir().context("create reviewer staging directory")?;
139        let local = staging.path().join("profile");
140        stage_profile(profile, &local).with_context(|| format!("stage profile {profile_id:?}"))?;
141        // Harnesses that ignore MCP servers offered over ACP read their own
142        // configuration instead, so the servers are written into the copy
143        // being staged, before it is uploaded.
144        if !mcp_servers.is_empty()
145            && ReviewMcpDelivery::for_harness(profile.kind) == ReviewMcpDelivery::HarnessProfile
146        {
147            configure_staged_review_mcp(profile.kind, &local, mcp_servers)
148                .with_context(|| format!("configure reviewer MCP servers for {profile_id:?}"))?;
149        }
150        upload_reviewer_profile(executor, &backend, &worker_root, generation, &local)?;
151
152        let (bridge_command, bridge_args) = bridge_launch(profile.kind, execution_policy);
153        let mut environment = profile.environment.clone();
154        // The worker sets the harness home from the directory it staged, so
155        // sending one here could only point the reviewer somewhere it must not
156        // read.
157        environment.remove(profile.home_env());
158        Ok(ReviewerLaunchConfig {
159            profile_id: profile_id.to_owned(),
160            harness: profile.kind,
161            bridge_command: bridge_command.into(),
162            bridge_args,
163            environment,
164            execution_policy,
165            model: None,
166            effort: None,
167            generation,
168            // A harness that reads its servers from the staged profile must
169            // not also be offered them over ACP: it would either duplicate the
170            // server or reject the request.
171            mcp_servers: match ReviewMcpDelivery::for_harness(profile.kind) {
172                ReviewMcpDelivery::Acp => mcp_servers.to_vec(),
173                ReviewMcpDelivery::HarnessProfile => Vec::new(),
174            },
175        })
176    }
177}
178
179/// Writes `servers` into the staged profile of a harness that reads its MCP
180/// configuration from disk.
181///
182/// Claude Code reads `mcpServers` from `.claude.json` in its config directory;
183/// Kimi reads `mcpServers` from `mcp.json` in its home, and needs the runtime
184/// id its own schema carries. Both files are the reviewer's private copy, so
185/// nothing here can reach the user's own configuration.
186fn configure_staged_review_mcp(
187    harness: hel::hel_config::HarnessKind,
188    profile_stage: &Path,
189    servers: &[ReviewMcpServer],
190) -> Result<()> {
191    let (file, kimi) = match harness {
192        hel::hel_config::HarnessKind::Claude => (".claude.json", false),
193        hel::hel_config::HarnessKind::Kimi => ("mcp.json", true),
194        other => bail!("{other:?} does not read MCP servers from its profile"),
195    };
196    let path = profile_stage.join(file);
197    let mut document = match std::fs::read(&path) {
198        Ok(body) => serde_json::from_slice::<serde_json::Value>(&body)
199            .with_context(|| format!("parse staged reviewer configuration {}", path.display()))?,
200        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
201            serde_json::Value::Object(serde_json::Map::new())
202        }
203        Err(error) => {
204            return Err(error)
205                .with_context(|| format!("read staged reviewer configuration {}", path.display()));
206        }
207    };
208    let root = document.as_object_mut().with_context(|| {
209        format!(
210            "staged reviewer configuration {} must contain a JSON object",
211            path.display()
212        )
213    })?;
214    let configured = root
215        .entry("mcpServers")
216        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
217        .as_object_mut()
218        .with_context(|| {
219            format!(
220                "mcpServers in staged reviewer configuration {} must be a JSON object",
221                path.display()
222            )
223        })?;
224    for server in servers {
225        let mut entry = serde_json::json!({
226            "command": server.command,
227            "args": server.args,
228        });
229        if kimi {
230            let object = entry
231                .as_object_mut()
232                .expect("the server entry is a JSON object");
233            object.insert("transport".into(), "stdio".into());
234            object.insert("runtime_id".into(), "local".into());
235        } else {
236            let object = entry
237                .as_object_mut()
238                .expect("the server entry is a JSON object");
239            object.insert("type".into(), "stdio".into());
240        }
241        configured.insert(server.name.clone(), entry);
242    }
243    let mut body = serde_json::to_vec_pretty(&document)?;
244    body.push(b'\n');
245    hel::hel_config::atomic_write(&path, &body)
246        .with_context(|| format!("write staged reviewer configuration {}", path.display()))
247}
248
249/// The review supervisor's dispatch tool, as it runs inside the container:
250/// this worker's own binary in `review-mcp` mode, talking to the socket the
251/// worker serves in its reviewer directory.
252fn review_dispatch_server(worker_root: &str) -> ReviewMcpServer {
253    let socket = format!(
254        "{worker_root}/{}/{}",
255        REVIEWER_DIR,
256        hel::hel_review::mcp::REVIEW_DISPATCH_SOCKET
257    );
258    ReviewMcpServer {
259        name: hel::hel_review::mcp::REVIEW_MCP_SERVER_NAME.to_owned(),
260        command: Path::new(worker_root).join("hel"),
261        args: vec![
262            "worker".to_owned(),
263            "review-mcp".to_owned(),
264            "--socket".to_owned(),
265            socket,
266        ],
267    }
268}
269
270/// Where one immutable reviewer profile snapshot lives on the target.
271///
272/// The worker copies this source into each role's private harness home before
273/// launch. Keeping generations in separate directories means staging a lane
274/// cannot replace the profile a running role is using.
275fn reviewer_profile_home(worker_root: &str, generation: u64) -> String {
276    reviewer_staging_profile_home(Path::new(worker_root), generation)
277        .to_string_lossy()
278        .into_owned()
279}
280
281/// Replace this generation's staged profile with a fresh copy of `local`.
282///
283/// The previous snapshot for this generation is removed first: a reviewer
284/// profile is a snapshot of the user's configured home, and merging a new copy
285/// over an old one would leave credentials and skills the source no longer has.
286fn upload_reviewer_profile(
287    executor: &impl CommandExecutor,
288    locator: &hel_targets::TargetLocator,
289    worker_root: &str,
290    generation: u64,
291    local: &Path,
292) -> Result<()> {
293    let home = reviewer_profile_home(worker_root, generation);
294    match locator {
295        hel_targets::TargetLocator::LocalBare { .. } => {
296            for command in [
297                CommandSpec::new("rm", ["-rf", "--", &home])
298                    .purpose("clear the local reviewer profile"),
299                CommandSpec::new("mkdir", ["-p", &home])
300                    .purpose("create the local reviewer profile directory"),
301                CommandSpec::new(
302                    "cp",
303                    [
304                        "-R".to_owned(),
305                        format!("{}/.", local.display()),
306                        home.clone(),
307                    ],
308                )
309                .purpose("install the local reviewer profile"),
310                CommandSpec::new("chmod", ["-R", "go-rwx", &home])
311                    .purpose("restrict local reviewer profile permissions"),
312            ] {
313                execute_checked(executor, command)?;
314            }
315        }
316        hel_targets::TargetLocator::LocalPodman { container_id, .. }
317        | hel_targets::TargetLocator::LocalDocker { container_id }
318        | hel_targets::TargetLocator::AppleContainer { container_id } => {
319            let engine = match locator {
320                hel_targets::TargetLocator::LocalPodman { .. } => "podman",
321                hel_targets::TargetLocator::LocalDocker { .. } => "docker",
322                hel_targets::TargetLocator::AppleContainer { .. } => "container",
323                _ => unreachable!("matched local container target"),
324            };
325            for arguments in [
326                vec![
327                    "exec".to_owned(),
328                    container_id.clone(),
329                    "rm".to_owned(),
330                    "-rf".to_owned(),
331                    "--".to_owned(),
332                    home.clone(),
333                ],
334                vec![
335                    "exec".to_owned(),
336                    container_id.clone(),
337                    "mkdir".to_owned(),
338                    "-p".to_owned(),
339                    home.clone(),
340                ],
341                vec![
342                    "cp".to_owned(),
343                    format!("{}/.", local.display()),
344                    format!("{container_id}:{home}"),
345                ],
346                container_upload_ownership_args(container_id, worker_root, &[&home]),
347                vec![
348                    "exec".to_owned(),
349                    container_id.clone(),
350                    "chmod".to_owned(),
351                    "-R".to_owned(),
352                    "go-rwx".to_owned(),
353                    home.clone(),
354                ],
355            ] {
356                execute_checked(
357                    executor,
358                    CommandSpec::new(engine, arguments).purpose("stage the reviewer profile"),
359                )?;
360            }
361        }
362        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
363        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
364            let incoming = format!("{home}.incoming");
365            execute_checked(
366                executor,
367                ssh_command_spec(ssh, ["mkdir", "-p", worker_root])
368                    .purpose("create the reviewer directory"),
369            )?;
370            execute_checked(
371                executor,
372                ssh_command_spec(ssh, ["rm", "-rf", "--", &incoming, &home])
373                    .purpose("clear the reviewer profile"),
374            )?;
375            execute_checked(
376                executor,
377                scp_command_spec(ssh, local, &incoming, true)
378                    .purpose("upload the reviewer profile"),
379            )?;
380            execute_checked(
381                executor,
382                ssh_command_spec(ssh, ["mv", &incoming, &home])
383                    .purpose("install the reviewer profile"),
384            )?;
385            execute_checked(
386                executor,
387                ssh_command_spec(ssh, ["chmod", "-R", "go-rwx", &home])
388                    .purpose("restrict reviewer profile permissions"),
389            )?;
390        }
391        hel_targets::TargetLocator::SshPodman {
392            ssh, container_id, ..
393        }
394        | hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
395            let engine = match locator {
396                hel_targets::TargetLocator::SshPodman { .. } => "podman",
397                hel_targets::TargetLocator::SshDocker { .. } => "docker",
398                _ => unreachable!("matched remote container target"),
399            };
400            let upload = format!("{worker_root}/.reviewer-upload-{generation}");
401            execute_checked(
402                executor,
403                ssh_command_spec(ssh, ["rm", "-rf", "--", &upload])
404                    .purpose("clear remote reviewer staging"),
405            )?;
406            execute_checked(
407                executor,
408                scp_command_spec(ssh, local, &upload, true)
409                    .purpose("upload the remote reviewer profile"),
410            )?;
411            for arguments in [
412                vec![
413                    engine.to_owned(),
414                    "exec".to_owned(),
415                    container_id.clone(),
416                    "rm".to_owned(),
417                    "-rf".to_owned(),
418                    "--".to_owned(),
419                    home.clone(),
420                ],
421                vec![
422                    engine.to_owned(),
423                    "exec".to_owned(),
424                    container_id.clone(),
425                    "mkdir".to_owned(),
426                    "-p".to_owned(),
427                    home.clone(),
428                ],
429                vec![
430                    engine.to_owned(),
431                    "cp".to_owned(),
432                    format!("{upload}/."),
433                    format!("{container_id}:{home}"),
434                ],
435                std::iter::once(engine.to_owned())
436                    .chain(container_upload_ownership_args(
437                        container_id,
438                        worker_root,
439                        &[&home],
440                    ))
441                    .collect(),
442                vec![
443                    engine.to_owned(),
444                    "exec".to_owned(),
445                    container_id.clone(),
446                    "chmod".to_owned(),
447                    "-R".to_owned(),
448                    "go-rwx".to_owned(),
449                    home.clone(),
450                ],
451            ] {
452                execute_checked(
453                    executor,
454                    ssh_command_spec(ssh, arguments).purpose("stage the remote reviewer profile"),
455                )?;
456            }
457            execute_checked(
458                executor,
459                ssh_command_spec(ssh, ["rm", "-rf", "--", &upload])
460                    .purpose("remove remote reviewer staging"),
461            )?;
462        }
463    }
464    if home.trim().is_empty() {
465        bail!("the reviewer profile home resolved to an empty path");
466    }
467    Ok(())
468}
469
470#[cfg(test)]
471mod tests {
472    use std::cell::RefCell;
473    use std::collections::BTreeMap;
474
475    use super::*;
476    use crate::hel_controller::test_support::checkpoint_test_session;
477    use hel::hel_config::{HarnessKind, HarnessProfile, HelConfig, TargetTemplate};
478    use hel::hel_state::{HelState, SessionState};
479    use hel::hel_targets::CommandOutput;
480
481    struct RecordingExecutor {
482        commands: RefCell<Vec<CommandSpec>>,
483    }
484
485    impl RecordingExecutor {
486        fn new() -> Self {
487            Self {
488                commands: RefCell::new(Vec::new()),
489            }
490        }
491
492        /// Every command as one line, for order-sensitive assertions.
493        fn script(&self) -> Vec<String> {
494            self.commands
495                .borrow()
496                .iter()
497                .map(|command| format!("{} {}", command.program, command.args.join(" ")))
498                .collect()
499        }
500    }
501
502    impl CommandExecutor for RecordingExecutor {
503        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
504            self.commands.borrow_mut().push(command.clone());
505            Ok(CommandOutput {
506                status: 0,
507                stdout: Vec::new(),
508                stderr: Vec::new(),
509            })
510        }
511    }
512
513    /// A controller with one running session and two configured profiles: the
514    /// session's own and a second one to review with.
515    const SESSION_ID: &str = "0123456789abcdef0123456789abcdef";
516
517    fn fixture(directory: &Path, locator: hel::hel_state::TargetLocator) -> (Controller, String) {
518        let session_id = SESSION_ID;
519        let mut session = checkpoint_test_session(session_id);
520        session.target_template_id = "local".into();
521        session.state = SessionState::Running;
522        session.target = Some(locator);
523        let mut config = HelConfig::default();
524        config
525            .targets
526            .insert("local".into(), TargetTemplate::LocalBare);
527        for (id, kind) in [
528            ("codex", HarnessKind::Codex),
529            ("claude", HarnessKind::Claude),
530        ] {
531            let home = directory.join(id);
532            std::fs::create_dir_all(&home).unwrap();
533            config.profiles.insert(
534                id.to_owned(),
535                HarnessProfile {
536                    enabled: true,
537                    kind,
538                    home,
539                    environment: BTreeMap::from([("EXTRA".into(), "1".into())]),
540                    context_window_bytes: None,
541                },
542            );
543        }
544        (
545            Controller {
546                config,
547                state: HelState {
548                    sessions: BTreeMap::from([(session_id.into(), session)]),
549                    ..HelState::default()
550                },
551            },
552            session_id.to_owned(),
553        )
554    }
555
556    #[test]
557    fn staging_copies_the_chosen_profile_into_the_worker_root() {
558        let directory = tempfile::tempdir().unwrap();
559        let worker_root = directory.path().join(SESSION_ID);
560        std::fs::create_dir_all(directory.path().join("claude")).unwrap();
561        // A file the allowlist copies, so the stage has something to move.
562        std::fs::write(directory.path().join("claude/CLAUDE.md"), b"reviewer").unwrap();
563        let (controller, session_id) = fixture(
564            directory.path(),
565            hel::hel_state::TargetLocator::LocalBare {
566                worker_root: worker_root.clone(),
567            },
568        );
569        let executor = RecordingExecutor::new();
570
571        let config = controller
572            .stage_reviewer_profile_controlled(&session_id, "claude", 0, &[], &executor)
573            .unwrap();
574
575        assert_eq!(config.profile_id, "claude");
576        assert_eq!(config.harness, HarnessKind::Claude);
577        assert_eq!(config.generation, 0);
578        assert_eq!(config.model, None);
579        assert_eq!(config.effort, None);
580        // The worker owns the harness home, so the controller never sends one.
581        assert!(
582            !config
583                .environment
584                .contains_key(HarnessKind::Claude.home_env())
585        );
586        assert_eq!(
587            config.environment.get("EXTRA").map(String::as_str),
588            Some("1")
589        );
590
591        let home = format!("{}/reviewer/profile", worker_root.display());
592        let script = executor.script();
593        let cleared = script
594            .iter()
595            .position(|line| line.starts_with("rm ") && line.contains(&home))
596            .expect("the previous reviewer profile is cleared");
597        let copied = script
598            .iter()
599            .position(|line| line.starts_with("cp ") && line.ends_with(&home))
600            .expect("the staged profile is installed");
601        assert!(
602            cleared < copied,
603            "a stale profile must go before the new one lands: {script:?}"
604        );
605        assert!(
606            script
607                .iter()
608                .any(|line| line.contains("go-rwx") && line.contains(&home)),
609            "the reviewer profile must not be world readable: {script:?}"
610        );
611    }
612
613    #[test]
614    fn local_container_targets_stage_the_reviewer_through_their_engine() {
615        let directory = tempfile::tempdir().unwrap();
616        let container_id = hel::hel_targets::resource_name(SESSION_ID).unwrap();
617        for (locator, engine) in [
618            (
619                hel::hel_state::TargetLocator::LocalPodman {
620                    container_id: container_id.clone(),
621                    workspace_storage: Default::default(),
622                },
623                "podman",
624            ),
625            (
626                hel::hel_state::TargetLocator::LocalDocker {
627                    container_id: container_id.clone(),
628                },
629                "docker",
630            ),
631        ] {
632            let (controller, session_id) = fixture(directory.path(), locator);
633            let executor = RecordingExecutor::new();
634
635            controller
636                .stage_reviewer_profile_controlled(&session_id, "codex", 3, &[], &executor)
637                .unwrap();
638
639            let script = executor.script();
640            assert!(
641                script
642                    .iter()
643                    .all(|line| line.starts_with(&format!("{engine} "))),
644                "a container target is reached only through its engine: {script:?}"
645            );
646            let home = script
647                .iter()
648                .find_map(|line| {
649                    line.split(' ')
650                        .find(|word| word.contains("/reviewer/profile"))
651                })
652                .expect("the reviewer profile is placed")
653                .to_owned();
654            assert!(
655                home.contains(&format!("/{session_id}")),
656                "the reviewer lives under this session's worker root: {home}"
657            );
658            // Nothing here provisions a target, a checkout, or another session.
659            assert!(
660                !script.iter().any(|line| {
661                    line.contains("run") || line.contains("git") || line.contains("create")
662                }),
663                "staging a reviewer provisions nothing: {script:?}"
664            );
665        }
666    }
667
668    #[test]
669    fn an_unknown_profile_is_refused_before_anything_is_copied() {
670        let directory = tempfile::tempdir().unwrap();
671        let (controller, session_id) = fixture(
672            directory.path(),
673            hel::hel_state::TargetLocator::LocalBare {
674                worker_root: directory.path().join(SESSION_ID),
675            },
676        );
677        let executor = RecordingExecutor::new();
678
679        let error = controller
680            .stage_reviewer_profile_controlled(&session_id, "missing", 0, &[], &executor)
681            .unwrap_err();
682
683        assert!(format!("{error:#}").contains("unknown profile"));
684        assert!(executor.commands.borrow().is_empty());
685    }
686
687    #[test]
688    fn a_new_generation_travels_to_the_worker_so_it_starts_a_fresh_reviewer() {
689        let directory = tempfile::tempdir().unwrap();
690        let (controller, session_id) = fixture(
691            directory.path(),
692            hel::hel_state::TargetLocator::LocalBare {
693                worker_root: directory.path().join(SESSION_ID),
694            },
695        );
696        let executor = RecordingExecutor::new();
697
698        let first = controller
699            .stage_reviewer_profile_controlled(&session_id, "codex", 0, &[], &executor)
700            .unwrap();
701        let second = controller
702            .stage_reviewer_profile_controlled(&session_id, "codex", 1, &[], &executor)
703            .unwrap();
704
705        assert!(first.reusable_for(&first));
706        assert!(
707            !first.reusable_for(&second),
708            "a new generation must not reload the old conversation"
709        );
710    }
711}