netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
//! STOMP 1.2 client implementation.
//!
//! # The handshake is a gate, not a formality
//!
//! `connect()` does not return until the broker has answered `CONNECT` with a well-formed
//! `CONNECTED` carrying a `version` header naming 1.2. Anything else — an `ERROR`, a
//! `CONNECTED` with no version, a version we did not offer, some other command, silence until
//! the timeout, or a closed socket — is an `Err`, so `client_startup` records
//! [`ClientStatus::Error`] and no `stomp_connected` event is ever raised. This is exactly the
//! strictness `async-stomp` applies to *our* server (its `Connector::connect()` refuses to
//! hand back a transport otherwise), and it is the difference between a client that reports a
//! session and a client that has one.
//!
//! # No per-connection state machine, deliberately
//!
//! `src/server/tcp/mod.rs` carries Idle/Processing/Accumulating because raw TCP has no frame
//! boundaries, so a second read arriving mid-LLM-call has to be queued somewhere. STOMP *has*
//! frame boundaries: this loop reads, parses out whole frames, and handles them one at a time
//! in the same task. Bytes arriving during a model call sit in the socket buffer — TCP's own
//! backpressure — and are parsed on the next pass. There is no window in which two model calls
//! could overlap on this connection, so there is nothing to track.
//!
//! # Why there is no boxed follow-up recursion here
//!
//! The root `CLAUDE.md` requires a depth-bounded, boxed recursive call for clients whose
//! action → event → action cycle is self-referential in-process. This client's cycle is not:
//! every action it can take puts a frame on the wire, and the answer comes back as another
//! frame that the same read loop parses and reports. The chain continues by itself, through
//! the socket, exactly as `datalink`'s does through its pcap queue — so the model can
//! subscribe, be told about the `MESSAGE` that arrives, and publish in response, without any
//! function here calling itself. The only bound needed is the one every client already has,
//! `client/llm_budget.rs`.
//!
//! # Graceful shutdown
//!
//! The `disconnect` action does not hang up. It writes `DISCONNECT` with a fixed `receipt`
//! ([`actions::DISCONNECT_RECEIPT_ID`]) and this loop closes when the matching `RECEIPT`
//! arrives — the specification's shutdown, and the only thing that tells a publisher the
//! broker durably took what it sent. A broker that never answers is bounded by
//! [`DISCONNECT_GRACE`] rather than leaving the loop parked forever.

pub mod actions;

pub use actions::StompClientProtocol;

use anyhow::{anyhow, bail, Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, error, info, trace, warn};

use crate::client::llm_budget::call_llm_for_client;
use crate::client::stomp::actions::{
    DISCONNECT_RECEIPT_ID, STOMP_ACCEPT_VERSION, STOMP_CLIENT_CONNECTED_EVENT,
    STOMP_CLIENT_ERROR_RECEIVED_EVENT, STOMP_CLIENT_MESSAGE_RECEIVED_EVENT,
    STOMP_CLIENT_RECEIPT_RECEIVED_EVENT, STOMP_HEARTBEAT,
};
use crate::llm::actions::client_trait::Client;
use crate::llm::ollama_client::OllamaClient;
use crate::protocol::{Event, StartupParams};
use crate::server::stomp::frame::{self, ParseOutcome, StompFrame};
use crate::state::app_state::AppState;
use crate::state::{ClientId, ClientStatus};

/// How long to wait for the broker's `CONNECTED` when the caller declares no preference.
const DEFAULT_HANDSHAKE_TIMEOUT_SECS: u64 = 20;

/// How long to wait for the `RECEIPT` acknowledging our own `DISCONNECT` before closing anyway.
///
/// A broker is entitled to close the socket the moment it sees `DISCONNECT` without ever
/// sending the receipt, and some do. Without this bound the loop would sit on a connection
/// nobody is going to speak on again.
const DISCONNECT_GRACE: Duration = Duration::from_secs(10);

/// STOMP 1.2 client.
pub struct StompClient;

/// What the loop should do after handling a frame or a batch of actions.
enum Flow {
    /// Keep reading.
    Continue,
    /// A `DISCONNECT` was written; close once its `RECEIPT` arrives or the grace period lapses.
    DisconnectSent,
    /// The session is over now.
    Close,
}

impl StompClient {
    /// Open a STOMP session and drive it from the model.
    ///
    /// Returns the local socket address once the handshake has completed and been validated.
    pub async fn connect_with_llm_actions(
        remote_addr: String,
        llm_client: OllamaClient,
        app_state: Arc<AppState>,
        status_tx: mpsc::UnboundedSender<String>,
        client_id: ClientId,
        startup_params: Option<StartupParams>,
    ) -> Result<SocketAddr> {
        // Every accessor is fallible and every failure names the offending key: these values
        // come from the model or an MCP caller, so `?` rather than `unwrap()`.
        let (host_param, login, passcode, use_stomp_command, handshake_timeout_secs) =
            match &startup_params {
                Some(params) => (
                    params.get_optional_string("host")?,
                    params.get_optional_string("login")?,
                    params.get_optional_string("passcode")?,
                    params
                        .get_optional_bool("use_stomp_command")?
                        .unwrap_or(false),
                    params
                        .get_optional_u64("handshake_timeout_secs")?
                        .unwrap_or(DEFAULT_HANDSHAKE_TIMEOUT_SECS),
                ),
                None => (None, None, None, false, DEFAULT_HANDSHAKE_TIMEOUT_SECS),
            };

        let stream = TcpStream::connect(&remote_addr)
            .await
            .with_context(|| format!("Failed to connect to STOMP broker at {remote_addr}"))?;
        let local_addr = stream.local_addr()?;
        let peer_addr = stream.peer_addr()?;

        let (mut read_half, write_half) = tokio::io::split(stream);
        let write_half = Arc::new(Mutex::new(write_half));

        // The specification says a client sends the host name it established the socket to
        // when it has nothing better; the virtual host is the "better" a broker configuration
        // supplies.
        let host = host_param.unwrap_or_else(|| host_of(&remote_addr));

        // CONNECT is exempt from STOMP 1.2 header escaping (see `frame::should_escape`), so
        // these three values reach the wire raw and nothing downstream can neutralise a
        // newline in one. A `login` containing `\n` would forge an extra header on the
        // handshake frame, and `\n\n` would end the header block. They come from startup
        // parameters, which is the model or an MCP caller, so they are checked rather than
        // trusted. Rejecting rather than escaping: a 1.0/1.1 broker would read an escape
        // sequence literally, which is the whole reason the command is exempt.
        for (field, value) in [
            ("host", Some(host.as_str())),
            ("login", login.as_deref()),
            ("passcode", passcode.as_deref()),
        ] {
            if let Some(value) = value {
                if !crate::server::stomp::frame::is_safe_unescaped_header(value) {
                    anyhow::bail!(
                        "STOMP startup parameter '{field}' may not contain a newline, carriage \
                         return, colon or NUL: CONNECT headers are exempt from 1.2 escaping, so \
                         such a value would forge a header rather than appear in this one"
                    );
                }
            }
        }

        let connect_frame = build_connect_frame(
            use_stomp_command,
            &host,
            login.as_deref(),
            passcode.as_deref(),
        );
        {
            let mut guard = write_half.lock().await;
            guard
                .write_all(&connect_frame)
                .await
                .context("Failed to write the STOMP CONNECT frame")?;
            guard.flush().await.context("Failed to flush CONNECT")?;
        }
        debug!(
            "STOMP client {} sent {} to {} (host={}, accept-version={}, heart-beat={})",
            client_id,
            if use_stomp_command {
                "STOMP"
            } else {
                "CONNECT"
            },
            peer_addr,
            host,
            STOMP_ACCEPT_VERSION,
            STOMP_HEARTBEAT
        );

        // Anything left in the buffer after the handshake belongs to the session — a broker is
        // free to pipeline a MESSAGE immediately behind CONNECTED, and dropping the remainder
        // would lose it.
        let mut pending: Vec<u8> = Vec::new();
        let reply = read_one_frame(
            &mut read_half,
            &mut pending,
            Duration::from_secs(handshake_timeout_secs),
        )
        .await
        .context("STOMP handshake failed")?;

        let (version, session, server) = validate_connected(&reply)?;

        info!(
            "STOMP client {} connected to {} (version={}, session={}, server={})",
            client_id,
            peer_addr,
            version,
            if session.is_empty() {
                "-"
            } else {
                session.as_str()
            },
            if server.is_empty() {
                "-"
            } else {
                server.as_str()
            }
        );
        app_state
            .update_client_status(client_id, ClientStatus::Connected)
            .await;
        let _ = status_tx.send(format!(
            "[CLIENT] STOMP client {client_id} connected to {peer_addr} (session {})",
            if session.is_empty() {
                "-"
            } else {
                session.as_str()
            }
        ));
        let _ = status_tx.send("__UPDATE_UI__".to_string());

        // Registered BEFORE the connected event is handled: a `manual` routing rule can park
        // that event at the dashboard for minutes, and until registration the UI reports "no
        // command channel" — which reads as a protocol limitation when it is only a queue.
        let mut command_rx =
            crate::client::command_support::register_command_channel(&app_state, client_id).await;

        let protocol = StompClientProtocol::new();
        let memory = Arc::new(Mutex::new(
            app_state
                .get_memory_for_client(client_id)
                .await
                .unwrap_or_default(),
        ));

        let mut disconnect_deadline: Option<tokio::time::Instant> = None;

        let connected_event = Event::new(
            &STOMP_CLIENT_CONNECTED_EVENT,
            serde_json::json!({
                "version": version,
                "session": session,
                "server": server,
            }),
        );
        match report_event(
            connected_event,
            &protocol,
            &write_half,
            &llm_client,
            &app_state,
            &status_tx,
            client_id,
            &memory,
        )
        .await
        {
            Flow::Close => {
                // Nothing to unwind: the loop has not started, so close here and report the
                // session as over rather than spawning a task with nothing to do.
                let _ = write_half.lock().await.shutdown().await;
                app_state.remove_client_handle(client_id).await;
                app_state
                    .update_client_status(client_id, ClientStatus::Disconnected)
                    .await;
                let _ = status_tx.send("__UPDATE_UI__".to_string());
                return Ok(local_addr);
            }
            Flow::DisconnectSent => {
                disconnect_deadline = Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
            }
            Flow::Continue => {}
        }

        // Registered with AppState so stop_client can abort it — dropping a JoinHandle only
        // detaches the task in Tokio, and an un-aborted read loop keeps the socket alive after
        // the client has been removed.
        let task_registrar = app_state.clone();
        let task_handle = tokio::spawn(async move {
            run_session(
                read_half,
                write_half,
                pending,
                &mut command_rx,
                disconnect_deadline,
                protocol,
                llm_client,
                app_state.clone(),
                status_tx.clone(),
                client_id,
                memory,
            )
            .await;

            // Every exit lands here. The loop owns the only receiver, so dropping the
            // registered handle makes a later send_to_client fail fast instead of timing out.
            app_state.remove_client_handle(client_id).await;
            let _ = status_tx.send("__UPDATE_UI__".to_string());
        });
        task_registrar
            .register_client_task(client_id, task_handle)
            .await;

        Ok(local_addr)
    }
}

/// The read loop: parse frames, report each to the model, write whatever it answers with.
#[allow(clippy::too_many_arguments)]
async fn run_session(
    mut read_half: tokio::io::ReadHalf<TcpStream>,
    write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
    mut pending: Vec<u8>,
    command_rx: &mut mpsc::Receiver<crate::state::client_handles::ClientCommand>,
    mut disconnect_deadline: Option<tokio::time::Instant>,
    protocol: StompClientProtocol,
    llm_client: OllamaClient,
    app_state: Arc<AppState>,
    status_tx: mpsc::UnboundedSender<String>,
    client_id: ClientId,
    memory: Arc<Mutex<String>>,
) {
    let mut read_buf = vec![0u8; 8192];

    'session: loop {
        // Drain every complete frame already buffered before asking the socket for more: a
        // single read routinely carries several, and a broker that pipelines must not have its
        // later frames wait for its next write.
        loop {
            let outcome = match frame::parse_frame(&pending) {
                Ok(outcome) => outcome,
                Err(e) => {
                    // STOMP cannot resynchronise a stream once framing is lost, so this is
                    // fatal for the connection rather than something to skip past.
                    error!("STOMP client {client_id} could not parse the broker's frame: {e}");
                    let _ = status_tx.send(format!(
                        "[CLIENT] ✖ STOMP client {client_id} framing error: {e}"
                    ));
                    break 'session;
                }
            };
            match outcome {
                ParseOutcome::Incomplete => break,
                ParseOutcome::Heartbeat { consumed } => {
                    trace!("STOMP client {client_id} drained {consumed} inter-frame EOL byte(s)");
                    pending.drain(..consumed);
                }
                ParseOutcome::Frame { frame, consumed } => {
                    pending.drain(..consumed);
                    match handle_frame(
                        frame,
                        &protocol,
                        &write_half,
                        &llm_client,
                        &app_state,
                        &status_tx,
                        client_id,
                        &memory,
                    )
                    .await
                    {
                        Flow::Continue => {}
                        Flow::DisconnectSent => {
                            disconnect_deadline =
                                Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
                        }
                        Flow::Close => break 'session,
                    }
                }
            }
        }

        let grace = async {
            match disconnect_deadline {
                Some(deadline) => tokio::time::sleep_until(deadline).await,
                None => std::future::pending::<()>().await,
            }
        };

        tokio::select! {
            // `AsyncReadExt::read` is cancellation-safe, so losing this branch to another arm
            // does not drop bytes.
            read = read_half.read(&mut read_buf) => match read {
                Ok(0) => {
                    info!("STOMP client {client_id} — broker closed the connection");
                    break 'session;
                }
                Ok(n) => {
                    trace!("STOMP client {client_id} read {n} byte(s)");
                    pending.extend_from_slice(&read_buf[..n]);
                }
                Err(e) => {
                    error!("STOMP client {client_id} read error: {e}");
                    app_state
                        .update_client_status(client_id, ClientStatus::Error(e.to_string()))
                        .await;
                    let _ = status_tx.send("__UPDATE_UI__".to_string());
                    return;
                }
            },

            Some(command) = command_rx.recv() => {
                // Peeked before the command is moved into the generic arm, which writes the
                // frame but cannot know what it means. `disconnect` produces a DISCONNECT
                // frame rather than a socket close, so the loop has to start the grace timer
                // itself — for an injected action exactly as for a model-produced one.
                let is_disconnect = command
                    .action
                    .get("type")
                    .and_then(|v| v.as_str())
                    == Some("disconnect");

                let should_break = crate::client::command_support::handle_stream_client_command(
                    &protocol,
                    &write_half,
                    command,
                    client_id,
                    &app_state,
                    &status_tx,
                )
                .await;

                if is_disconnect {
                    disconnect_deadline = Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
                }
                if should_break {
                    break 'session;
                }
            }

            _ = grace => {
                warn!(
                    "STOMP client {client_id} closing without the RECEIPT for its DISCONNECT \
                     ({}s grace elapsed) — the broker acknowledged nothing",
                    DISCONNECT_GRACE.as_secs()
                );
                break 'session;
            }
        }
    }

    let _ = write_half.lock().await.shutdown().await;
    app_state
        .update_client_status(client_id, ClientStatus::Disconnected)
        .await;
    let _ = status_tx.send(format!("[CLIENT] STOMP client {client_id} disconnected"));
}

/// Turn one inbound frame into an event, ask the model, and run its answer.
#[allow(clippy::too_many_arguments)]
async fn handle_frame(
    frame: StompFrame,
    protocol: &StompClientProtocol,
    write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
    llm_client: &OllamaClient,
    app_state: &Arc<AppState>,
    status_tx: &mpsc::UnboundedSender<String>,
    client_id: ClientId,
    memory: &Arc<Mutex<String>>,
) -> Flow {
    match frame.command.as_str() {
        "MESSAGE" => {
            let (body, body_encoding) = body_for_event(&frame.body);
            let event = Event::new(
                &STOMP_CLIENT_MESSAGE_RECEIVED_EVENT,
                serde_json::json!({
                    "destination": frame.header("destination").unwrap_or_default(),
                    "message_id": frame.header("message-id").unwrap_or_default(),
                    "subscription": frame.header("subscription").unwrap_or_default(),
                    "headers": frame.headers_except(&["destination", "message-id", "subscription"]),
                    "body": body,
                    "body_encoding": body_encoding,
                }),
            );
            debug!(
                "STOMP client {client_id} received MESSAGE on {} (subscription {})",
                frame.header("destination").unwrap_or("-"),
                frame.header("subscription").unwrap_or("-")
            );
            report_event(
                event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
            )
            .await
        }

        "RECEIPT" => {
            let receipt_id = frame.header("receipt-id").unwrap_or_default().to_string();

            // The acknowledgement of our own DISCONNECT ends the session. Reporting it to the
            // model would spend a call on a connection whose answer could not be written
            // anywhere; the log records it instead.
            if receipt_id == DISCONNECT_RECEIPT_ID {
                info!(
                    "STOMP client {client_id} — broker acknowledged DISCONNECT, closing \
                     gracefully"
                );
                return Flow::Close;
            }

            debug!("STOMP client {client_id} received RECEIPT {receipt_id}");
            let event = Event::new(
                &STOMP_CLIENT_RECEIPT_RECEIVED_EVENT,
                serde_json::json!({ "receipt_id": receipt_id }),
            );
            report_event(
                event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
            )
            .await
        }

        "ERROR" => {
            let message = frame.header("message").unwrap_or_default().to_string();
            let (body, body_encoding) = body_for_event(&frame.body);
            warn!("STOMP client {client_id} received ERROR from the broker: {message}");
            let _ = status_tx.send(format!(
                "[CLIENT] ⚠ STOMP client {client_id} got an ERROR frame: {message}"
            ));
            let event = Event::new(
                &STOMP_CLIENT_ERROR_RECEIVED_EVENT,
                serde_json::json!({
                    "message": message,
                    "body": body,
                    "body_encoding": body_encoding,
                }),
            );
            // Reported so the model learns what happened and can record it, then closed
            // regardless of what it answers: the specification has the broker close the
            // connection immediately after an ERROR, so nothing written here would arrive.
            let _ = report_event(
                event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
            )
            .await;
            Flow::Close
        }

        "CONNECTED" => {
            // A second CONNECTED mid-session is not defined. Ignoring it is safer than
            // re-running the handshake, and it is not fatal the way a framing error is.
            warn!("STOMP client {client_id} ignoring an unexpected second CONNECTED frame");
            Flow::Continue
        }

        other => {
            warn!(
                "STOMP client {client_id} ignoring a frame with unexpected command {other:?} — \
                 a broker sends only MESSAGE, RECEIPT, ERROR and CONNECTED"
            );
            Flow::Continue
        }
    }
}

/// Ask the model about `event` and execute whatever it answers with.
#[allow(clippy::too_many_arguments)]
async fn report_event(
    event: Event,
    protocol: &StompClientProtocol,
    write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
    llm_client: &OllamaClient,
    app_state: &Arc<AppState>,
    status_tx: &mpsc::UnboundedSender<String>,
    client_id: ClientId,
    memory: &Arc<Mutex<String>>,
) -> Flow {
    let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
        debug!(
            "STOMP client {client_id} has no instruction; {} is not reported to the model",
            event.event_type.id
        );
        return Flow::Continue;
    };

    // Copied out and the guard dropped before the call: never hold a lock across an await that
    // performs I/O or an LLM call.
    let memory_snapshot = memory.lock().await.clone();

    match call_llm_for_client(
        llm_client,
        app_state,
        client_id.to_string(),
        &instruction,
        &memory_snapshot,
        Some(&event),
        protocol,
        status_tx,
    )
    .await
    {
        Ok(result) => {
            if let Some(updated) = result.memory_updates {
                *memory.lock().await = updated.clone();
                app_state.set_memory_for_client(client_id, updated).await;
            }
            run_actions(
                result.actions,
                protocol,
                write_half,
                app_state,
                status_tx,
                client_id,
            )
            .await
        }
        Err(e) => {
            // A STOMP client has no error frame to send: ERROR is a server frame, and inventing
            // wire traffic because our own backend failed would tell the broker something
            // untrue. The session stays open — the next delivery gets another chance — and the
            // failure is recorded in both logs.
            error!("STOMP client {client_id} LLM error on {}: {e}", event.id());
            let _ = status_tx.send(format!(
                "[CLIENT] ✖ STOMP client {client_id} could not answer {}: {e}",
                event.id()
            ));
            Flow::Continue
        }
    }
}

/// Execute the model's actions in order, writing every frame they produce.
async fn run_actions(
    actions: Vec<serde_json::Value>,
    protocol: &StompClientProtocol,
    write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
    app_state: &Arc<AppState>,
    status_tx: &mpsc::UnboundedSender<String>,
    client_id: ClientId,
) -> Flow {
    let mut flow = Flow::Continue;

    for action in actions {
        // `disconnect` writes a DISCONNECT frame rather than closing, so the loop learns from
        // the action's own name that a graceful shutdown is in progress.
        let is_disconnect = action.get("type").and_then(|v| v.as_str()) == Some("disconnect");

        let result = match protocol.execute_action(action.clone()) {
            Ok(result) => result,
            Err(e) => {
                warn!("STOMP client {client_id} rejected an action from the model: {e}");
                let _ = status_tx.send(format!(
                    "[CLIENT] ⚠ STOMP client {client_id} rejected an action: {e}"
                ));
                continue;
            }
        };

        for bytes in result.get_all_data() {
            let mut guard = write_half.lock().await;
            if let Err(e) = guard.write_all(&bytes).await {
                error!("STOMP client {client_id} write failed: {e}");
                return Flow::Close;
            }
            if let Err(e) = guard.flush().await {
                error!("STOMP client {client_id} flush failed: {e}");
                return Flow::Close;
            }
            drop(guard);
            trace!("STOMP client {client_id} wrote {} byte(s)", bytes.len());
        }

        if result.disconnects() {
            // Nothing in this protocol's vocabulary produces this today; it is here so a
            // future action that hangs up is honoured rather than silently ignored.
            app_state
                .update_client_status(client_id, ClientStatus::Disconnected)
                .await;
            return Flow::Close;
        }
        if is_disconnect {
            flow = Flow::DisconnectSent;
        }
    }

    flow
}

/// Build the opening `CONNECT` (or `STOMP`) frame.
///
/// `heart-beat` is always `0,0`: this client runs no heart-beat timer, and a broker told
/// otherwise tears the connection down when the promised bare EOLs do not arrive.
fn build_connect_frame(
    use_stomp_command: bool,
    host: &str,
    login: Option<&str>,
    passcode: Option<&str>,
) -> Vec<u8> {
    let mut headers = vec![
        (
            "accept-version".to_string(),
            STOMP_ACCEPT_VERSION.to_string(),
        ),
        ("host".to_string(), host.to_string()),
        ("heart-beat".to_string(), STOMP_HEARTBEAT.to_string()),
    ];
    if let Some(login) = login {
        headers.push(("login".to_string(), login.to_string()));
    }
    if let Some(passcode) = passcode {
        headers.push(("passcode".to_string(), passcode.to_string()));
    }
    let command = if use_stomp_command {
        "STOMP"
    } else {
        "CONNECT"
    };
    StompFrame::new(command, headers, Vec::new()).encode()
}

/// Check the broker's reply to `CONNECT`, returning `(version, session, server)`.
///
/// Every failure here is a refusal to proceed. A client that treats a missing `version` as
/// "probably fine" has no idea which dialect the following frames are in, and STOMP 1.1 and
/// 1.2 disagree about the very headers this client's `ACK` uses.
fn validate_connected(reply: &StompFrame) -> Result<(String, String, String)> {
    match reply.command.as_str() {
        "CONNECTED" => {}
        "ERROR" => {
            let message = reply.header("message").unwrap_or("(no message header)");
            bail!(
                "the broker refused the session with an ERROR frame: {message}. This client \
                 offers accept-version {STOMP_ACCEPT_VERSION} and heart-beat \
                 {STOMP_HEARTBEAT}."
            );
        }
        other => bail!(
            "expected CONNECTED in reply to CONNECT, got a {other:?} frame. The session is not \
             open and no frame will be sent on it."
        ),
    }

    let Some(version) = reply.header("version") else {
        bail!(
            "the broker's CONNECTED frame carries no 'version' header, which STOMP 1.2 \
             requires. Without it there is no way to know which dialect the following frames \
             are in, so the session is refused rather than guessed at."
        );
    };
    if version != STOMP_ACCEPT_VERSION {
        bail!(
            "the broker chose STOMP version {version:?}, which this client did not offer — its \
             accept-version was {STOMP_ACCEPT_VERSION:?} and it implements nothing else. \
             Speaking a dialect we do not implement would corrupt the session rather than \
             degrade it."
        );
    }

    Ok((
        version.to_string(),
        reply.header("session").unwrap_or_default().to_string(),
        reply.header("server").unwrap_or_default().to_string(),
    ))
}

/// Read frames into `pending` until one parses, or the deadline passes.
///
/// Used for the handshake only. Bytes past the frame stay in `pending` — a broker may pipeline
/// a `MESSAGE` immediately behind `CONNECTED`, and discarding the remainder would lose it.
async fn read_one_frame<R>(
    reader: &mut R,
    pending: &mut Vec<u8>,
    timeout: Duration,
) -> Result<StompFrame>
where
    R: tokio::io::AsyncRead + Unpin,
{
    let deadline = tokio::time::Instant::now() + timeout;
    let mut buf = vec![0u8; 4096];

    loop {
        match frame::parse_frame(pending).map_err(|e| anyhow!("{e}"))? {
            ParseOutcome::Frame { frame, consumed } => {
                pending.drain(..consumed);
                return Ok(frame);
            }
            ParseOutcome::Heartbeat { consumed } => {
                pending.drain(..consumed);
                continue;
            }
            ParseOutcome::Incomplete => {}
        }

        let n = tokio::time::timeout_at(deadline, reader.read(&mut buf))
            .await
            .map_err(|_| {
                anyhow!(
                    "the broker sent no complete frame within {}s ({} byte(s) buffered)",
                    timeout.as_secs(),
                    pending.len()
                )
            })?
            .context("read failed while waiting for the broker's reply")?;
        if n == 0 {
            bail!(
                "the broker closed the connection without replying ({} byte(s) buffered)",
                pending.len()
            );
        }
        pending.extend_from_slice(&buf[..n]);
    }
}

/// The host part of `host:port`, which is what a STOMP `host` header defaults to.
///
/// Bracketed IPv6 literals keep their brackets stripped rather than being split on the wrong
/// colon; anything unrecognisable is returned whole, because a wrong-but-present `host` header
/// is better than an absent one that makes the broker reject the frame outright.
fn host_of(remote_addr: &str) -> String {
    let trimmed = remote_addr.trim();
    if let Some(rest) = trimmed.strip_prefix('[') {
        if let Some(end) = rest.find(']') {
            return rest[..end].to_string();
        }
    }
    match trimmed.rsplit_once(':') {
        Some((host, _port)) if !host.is_empty() => host.to_string(),
        _ => trimmed.to_string(),
    }
}

/// Render a frame body for an event: text when it is printable, hex when it is not.
///
/// The `body_encoding` field says which, so nothing downstream has to guess — and passing the
/// pair straight into `send_stomp_send` reproduces the exact bytes. This mirrors the server's
/// own `body_for_event`, which is a private function there and so cannot be shared.
fn body_for_event(body: &[u8]) -> (String, &'static str) {
    if body
        .iter()
        .all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace())
    {
        (String::from_utf8_lossy(body).to_string(), "utf8")
    } else {
        (hex::encode(body), "hex")
    }
}