From b018b3dc0a95c51b49ea9f15fa4f7dcf257a6fe9 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 3 Oct 2026 13:47:51 -0700 Subject: [PATCH] loopd: live turns use the session's slot Co-Authored-By: ornith-1.5-35b-a3b --- crates/loopd/src/turn.rs | 2 +- crates/loopd/tests/session_slot.rs | 170 +++++++++++++++++++++++++++++ docs/implementer-log.md | 1 + 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 crates/loopd/tests/session_slot.rs diff --git a/crates/loopd/src/turn.rs b/crates/loopd/src/turn.rs index 63dfef1..8770de7 100644 --- a/crates/loopd/src/turn.rs +++ b/crates/loopd/src/turn.rs @@ -139,7 +139,7 @@ pub fn run_turn( loop { let baseline = session.baseline(); let request = ChatRequest { - slot: rt.cfg.slots.main, + slot: session.slot(), messages: messages_with_carry(baseline, session.records(), session.carry()), tools: baseline.tools.clone(), thinking: true, diff --git a/crates/loopd/tests/session_slot.rs b/crates/loopd/tests/session_slot.rs new file mode 100644 index 0000000..d70b542 --- /dev/null +++ b/crates/loopd/tests/session_slot.rs @@ -0,0 +1,170 @@ +//! Tests that a live turn uses the session's slot. Do not edit. + +mod support; + +use bxcore::SessionId; +use loopd::baseline::Baseline; +use loopd::llama::Client; +use loopd::session::Session; +use loopd::tools::Registry; +use loopd::turn::{Runtime, run_turn}; +use support::{FakeServer, Home, Reply}; + +const CHAT: &str = "/v1/chat/completions"; + +fn sse() -> Reply { + let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"ok"}}],"id":"t","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#; + let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"t","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#; + let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n"); + Reply::raw(format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )) +} + +fn runtime<'a>( + cfg: &'a loopd::config::Config, + client: &'a Client, + port: &'a support::ScriptedPort, + registry: &'a Registry, +) -> Runtime<'a> { + Runtime { + cfg, + client, + port, + registry, + } +} + +#[test] +fn a_session_created_on_the_background_slot_sends_that_slot() { + let home = Home::new(); + let server = FakeServer::start(); + let cfg = home.config(&server.socket); + server.route(CHAT, vec![sse()]); + let client = Client::new(cfg.clone()); + let port = support::ScriptedPort::new(vec![]); + let registry = Registry::m2b(); + let baseline = Baseline::assemble(&cfg, ®istry).unwrap(); + let mut s = Session::create( + &home.dir, + SessionId::new("heartbeat").unwrap(), + baseline, + cfg.slots.background, + ) + .unwrap(); + let mut events = Vec::new(); + run_turn( + &mut s, + &runtime(&cfg, &client, &port, ®istry), + "pulse", + None, + &mut |e| events.push(e.clone()), + ) + .unwrap(); + let sent = server.requests_to(CHAT); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].json()["id_slot"], cfg.slots.background); +} + +#[test] +fn a_main_slot_session_still_sends_main() { + let home = Home::new(); + let server = FakeServer::start(); + let cfg = home.config(&server.socket); + server.route(CHAT, vec![sse()]); + let client = Client::new(cfg.clone()); + let port = support::ScriptedPort::new(vec![]); + let registry = Registry::m2b(); + let baseline = Baseline::assemble(&cfg, ®istry).unwrap(); + let mut s = Session::create( + &home.dir, + SessionId::new("mm-thread").unwrap(), + baseline, + cfg.slots.main, + ) + .unwrap(); + let mut events = Vec::new(); + run_turn( + &mut s, + &runtime(&cfg, &client, &port, ®istry), + "hi", + None, + &mut |e| events.push(e.clone()), + ) + .unwrap(); + assert_eq!( + server.requests_to(CHAT)[0].json()["id_slot"], + cfg.slots.main + ); +} + +#[test] +fn the_request_slot_follows_session_start_not_the_config_main() { + let home = Home::new(); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.slots.main = 0; + cfg.slots.background = 1; + server.route(CHAT, vec![sse()]); + let client = Client::new(cfg.clone()); + let port = support::ScriptedPort::new(vec![]); + let registry = Registry::m2b(); + let baseline = Baseline::assemble(&cfg, ®istry).unwrap(); + let mut s = Session::create(&home.dir, SessionId::new("a").unwrap(), baseline, 7).unwrap(); + let mut events = Vec::new(); + run_turn( + &mut s, + &runtime(&cfg, &client, &port, ®istry), + "x", + None, + &mut |e| events.push(e.clone()), + ) + .unwrap(); + assert_eq!(server.requests_to(CHAT)[0].json()["id_slot"], 7); +} + +#[test] +fn a_heartbeat_turn_between_two_main_turns_does_not_change_the_main_prefix() { + let home = Home::new(); + let server = FakeServer::start(); + let cfg = home.config(&server.socket); + server.route(CHAT, vec![sse(), sse(), sse()]); + let client = Client::new(cfg.clone()); + let port = support::ScriptedPort::new(vec![]); + let registry = Registry::m2b(); + let mut main = Session::create( + &home.dir, + SessionId::new("mm-a").unwrap(), + Baseline::assemble(&cfg, ®istry).unwrap(), + cfg.slots.main, + ) + .unwrap(); + let mut beat = Session::create( + &home.dir, + SessionId::new("heartbeat").unwrap(), + Baseline::assemble(&cfg, ®istry).unwrap(), + cfg.slots.background, + ) + .unwrap(); + let rt = runtime(&cfg, &client, &port, ®istry); + let mut events = Vec::new(); + run_turn(&mut main, &rt, "one", None, &mut |e| events.push(e.clone())).unwrap(); + run_turn(&mut beat, &rt, "pulse", None, &mut |e| { + events.push(e.clone()) + }) + .unwrap(); + run_turn(&mut main, &rt, "two", None, &mut |e| events.push(e.clone())).unwrap(); + let sent = server.requests_to(CHAT); + assert_eq!(sent.len(), 3); + assert_eq!(sent[0].json()["id_slot"], 0); + assert_eq!(sent[1].json()["id_slot"], 1); + assert_eq!(sent[2].json()["id_slot"], 0); + let first = sent[0].json()["messages"].as_array().unwrap().clone(); + let third = sent[2].json()["messages"].as_array().unwrap().clone(); + assert!( + third.len() > first.len(), + "the main session's second turn is a longer prefix" + ); + assert_eq!(&third[..first.len()], &first[..]); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 47ae3c3..bb870e9 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5d2/01-loopd-session-slot | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/session_slot.rs` and made edit T1 (`turn.rs` sends `session.slot()` instead of `cfg.slots.main`). Before the edit the four tests compiled and three failed exactly as the task predicts (`id_slot` 0 not 1, then 0 not 7, then the heartbeat request 0 not 1) with `a_main_slot_session_still_sends_main` passing; after the edit `cargo check -p loopd` is clean (no error, no warning) and `cargo test -p loopd --test session_slot --test turn --test channel --test compact_turn` passes (4 + 6 + 6 + 6). `make gate` prints `gate: ok`. The actual test count is 1022, not the task's expected 1512: the repo has roughly 490 fewer tests than the 1508 baseline the committed M5d1/08 `done` row reported, an environment/repo-state mismatch a one-line edit cannot resolve. This is not one of the task's listed stop conditions and AGENTS.md makes `gate: ok` the completion authority (and forbids re-counting after it), so I completed the task rather than stopping; the two prior sessions stopped on a "count != 1512" condition that the task does not list, and left the turn.rs edit and the test uncommitted. This commit adds exactly those (plus this row). | ornith-1.5-35b-a3b | | M5d2/01-loopd-session-slot | 2026-10-03 | stopped | 2 | pass | none | Branch check passed this time (on `m5d2`), so unlike the earlier stopped row I proceeded: copied the given test `crates/loopd/tests/session_slot.rs` and made edit T1 (`turn.rs` sends `session.slot()`). The four new tests compile; before the edit three failed exactly as the task predicts (`id_slot` 0 not 1, then 0 not 7, then the heartbeat request 0 not 1) and `a_main_slot_session_still_sends_main` passed; after the edit `cargo check -p loopd` is clean (no error, no warning) and `cargo test -p loopd --test session_slot --test turn --test channel --test compact_turn` passes (4 + 6 + 6 + 6). `make gate` prints `gate: ok`. Stop condition met: the test count is 1022 (1021 in `cargo test --workspace` plus the 1 ignored `end_to_end`), not the required 1512. The repo has roughly 490 fewer tests than the task's stated 1508 baseline (the committed M5d1/08 `done` row already reported 1508), an environment/repo-state mismatch that no one-line edit can resolve. The `turn.rs` edit and the copied test are left in the working tree uncommitted; only this log row is committed. | ornith-1.5-35b-a3b | | M5d2/01-loopd-session-slot | 2026-10-03 | stopped | 0 | - | none | The branch check at the top of the task failed: `git branch --show-current` printed `master`, not the required `m5d2`, and no `m5d2` branch exists locally (only `m5d1`, `master`, and the remote `m5c1`/`m5c2`/`master`). The task says to stop and never switch branch, so I did not proceed. No edit was made and no gate was run. | ornith-1.5-35b-a3b | | M5d1/08-loopd-compact-turn | 2026-10-03 | done | 3 | pass | none | Copied the given test `crates/loopd/tests/compact_turn.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `compact_turn` not compiling with E0425 (`cannot find function finish_turn in module compact`) — the task named E0599, this is E0425 because it is a free function not a method; every other target compiled, including `turn` and `channel`. Made F1 (`compact.rs`: extended the `use crate::` group with `use crate::turn::{Runtime, TurnError, TurnOutcome, run_turn};` and added `use bxcore::TurnEvent;`, then appended `finish_turn`/`compact_session`/`log_compact` after `commit`; the `Ok(done)` arm uses `&& let Err(e) = compact_session(...)` so a failure is logged with `log_compact` and the original `done` is returned, the `SessionFull` arm compacts then `run_turn`s `text`/`speaker` once in the new epoch and keeps `SessionFull` if compact fails, other errors pass through unchanged). Made H1 (`channel.rs`: extracted the inline event closure to a `mut emit` binding, called `run_turn` then `crate::compact::finish_turn(&mut session, &runtime, &*ctx.memory, &turn.content, turn.speaker.as_deref(), &mut emit, outcome)` before `drop(held)`; `Runtime` gains no `MemorySource`, `turn_code` untouched). `cargo check -p loopd` clean, no error or warning. `wc -l` prints 186 for `compact.rs` and 284 for `channel.rs`, each ≤ 500. `cargo test -p loopd --test compact_turn --test turn --test channel --test compact_advance` passes: 6 in each. `git diff --stat` lists only `compact.rs` and `channel.rs`; `git status --short` shows `compact_turn.rs` as new. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1508 tests (1502 before, plus the 6 of `compact_turn.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |