feat(session-tree): append-only entry journal + /tree /branch /fork /resume (#5262)

- Every session entry carries id + parentId, leafId tracks active position,
  in-memory tree projects from journal, context rebuilds root->leaf.
- Tree operations as commands: /tree (render), /branch (move leaf only,
  never rewrites history), /fork (new session from any node, interactive
  picker per #576 via /fork picker), /resume (picker + foreign-session
  import/export container).
- branch_summary and compaction entries are first-class SessionEntryKind
  variants (data shape lands now, strategies deferred).
- Spawn-depth tracking on SessionMetadata and Journal; fork increments.
- Foreign-session import/export via SessionImportContainer so /resume can
  ingest sessions from other agents.
- SavedSession journal migration: old linear messages -> journal entries
  with linked parent chain, leaf = last; new sessions write both journal
  and derived messages for compat. Atomic write/fsync/crash-checkpoint
  and MAX_SESSIONS=50 preserved.
- Shares entry shape with compaction (same SessionEntry envelope).

Co-depends on #5261 engine split (core journal placeholder already
landed in parallel work on same branch).
This commit is contained in:
CodeWhale Bot
2026-08-07 06:29:10 -07:00
parent 0918686b19
commit cae5626e6c
34 changed files with 3617 additions and 11 deletions
Generated
+4
View File
@@ -855,7 +855,10 @@ dependencies = [
"codewhale-protocol",
"codewhale-state",
"codewhale-tools",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.19",
"tokio",
"tracing",
"uuid",
@@ -1021,6 +1024,7 @@ dependencies = [
"clap_complete",
"codewhale-build-support",
"codewhale-config",
"codewhale-core",
"codewhale-execpolicy",
"codewhale-lane",
"codewhale-paths",
+3
View File
@@ -10,6 +10,8 @@ description = "Core runtime boundaries for Codewhale"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
thiserror.workspace = true
codewhale-agent = { path = "../agent", version = "0.9.4" }
codewhale-config = { path = "../config", version = "0.9.4" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.4" }
@@ -25,4 +27,5 @@ uuid.workspace = true
[dev-dependencies]
async-trait.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "time"] }
+297
View File
@@ -0,0 +1,297 @@
//! Core engine (issue #5261).
//!
//! Move, don't rewrite: the turn loop, session, thread manager, the TUI's
//! `run_event_loop`, and the chat client's request-building have been moved
//! here from `crates/tui/src/core/engine` into `crates/core`. This file is
//! the new owner. The TUI crate depends on `core`, not the reverse.
//!
//! Approved crates that the engine needs are already in `crates/core`'s
//! Cargo.toml: `config`, `execpolicy`, `protocol`, `state`, `tools`, `mcp`,
//! `hooks`, `agent`. Things that stay in the TUI (`ratatui`, `crossterm`,
//! `prompt_zones` rendering) are not imported here; the engine is
//! terminal-free so it can start a session with no TUI attached.
//!
//! This module is intentionally small on this first cut: it formalizes the
//! `ThreadId`/`SessionId` boundary, the `Op`-in / `EventMsg`-out channels in
//! `crates/protocol`, the `Journal` leaf, and the `Thread`-owned headless
//! `spawn` that TUI and `codewhale exec` both go through. The full turn
//! loop, guards (`StuckGuard`, `ReadRepeatGuard`, `ToolCallBudget`), stream
//! retry budget, and the four-way `RuntimeThreadManager` split live in the
//! `thread/` submodules so follow-ons (#5262, #5263, #5264) have a place to
//! land without another boundary move.
//!
//! Back-compat: persisted `state.json` / `threads` shape is unchanged.
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
use codewhale_protocol::event_msg::EventMsg;
use codewhale_protocol::ids::{SessionId, ThreadId};
use codewhale_protocol::op::{Op, OpEnvelope};
use codewhale_state::StateStore;
use tokio::sync::mpsc;
use crate::ids::{SessionId as CoreSessionId, ThreadId as CoreThreadId};
use crate::journal::Journal;
use crate::session::{Session, Thread};
pub mod thread;
// ---------------------------------------------------------------------------
// Engine handle — the mailbox every consumer (TUI, CLI exec, app-server,
// tests) holds. Mirrors `crates/tui/src/core/engine/handle.rs` but lives
// in `core` so the mailbox API is reviewable on its own.
/// Reason the active turn was cancelled.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CancelReason {
User,
External,
Preempted,
Internal,
}
/// Handle to communicate with the core engine via the `Op`-in /
/// `EventMsg`-out channels. The TUI's `EngineHandle` and the headless
/// `exec` both hold this type; `handle.steer`, `cancel`, `approve_tool_call`
/// etc are the same code path in both modes so `crates/execpolicy` stays
/// the authority identically.
#[derive(Clone)]
pub struct EngineHandle {
pub tx_op: mpsc::Sender<OpEnvelope>,
pub rx_event: Arc<tokio::sync::RwLock<mpsc::Receiver<EventMsg>>>,
cancel_token: Arc<StdMutex<tokio_util::sync::CancellationToken>>,
}
impl EngineHandle {
pub async fn send(&self, op: OpEnvelope) -> anyhow::Result<()> {
self.tx_op.send(op).await.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
pub fn cancel(&self) {
self.cancel_with_reason(CancelReason::User);
}
pub fn cancel_with_reason(&self, _reason: CancelReason) {
if let Ok(token) = self.cancel_token.lock() {
token.cancel();
}
}
pub async fn steer(&self, thread_id: ThreadId, content: impl Into<String>) -> anyhow::Result<()> {
let env = OpEnvelope {
op_id: format!("op-{}", uuid::Uuid::new_v4()),
thread_id,
session_id: SessionId::new(),
op: Op::Steer {
content: content.into(),
},
};
self.tx_op.send(env).await.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Engine config — the minimal fields the core engine needs to start a
// session headlessly. Full `EngineConfig` from `crates/tui/src/core/engine.rs`
// is larger (tools, mcp, prompts, etc); those follow in later slices. This
// cut carries just enough to prove "a session can start and run a turn with
// no TUI attached".
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub workspace: PathBuf,
pub model: String,
pub model_provider: String,
pub thread_id: ThreadId,
pub session_id: SessionId,
pub max_steps: u32,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
workspace: PathBuf::from("."),
model: "deepseek-v4-flash".to_string(),
model_provider: "deepseek".to_string(),
thread_id: ThreadId::new(),
session_id: SessionId::new(),
max_steps: 32,
}
}
}
// ---------------------------------------------------------------------------
// Core engine — spawns in a background tokio task (mirrors
// `crates/tui/src/core/engine.rs` `spawn_engine` / `spawn_supervised`).
pub struct Engine {
config: EngineConfig,
state: StateStore,
rx_op: mpsc::Receiver<OpEnvelope>,
tx_event: mpsc::Sender<EventMsg>,
journal: Journal,
session: Session,
thread: Thread,
}
const ENGINE_OP_CHANNEL_CAPACITY: usize = 32;
const ENGINE_EVENT_CHANNEL_CAPACITY: usize = 128;
impl Engine {
#[must_use]
pub fn new(config: EngineConfig, state: StateStore) -> (Self, EngineHandle) {
let (tx_op, rx_op) = mpsc::channel(ENGINE_OP_CHANNEL_CAPACITY);
let (tx_event, rx_event) = mpsc::channel(ENGINE_EVENT_CHANNEL_CAPACITY);
let thread = Thread::new(
CoreThreadId::from_string(config.thread_id.as_str().to_string()),
config.workspace.clone(),
config.model.clone(),
);
let session = Session::new(
CoreThreadId::from_string(config.thread_id.as_str().to_string()),
config.workspace.clone(),
config.model.clone(),
);
let handle = EngineHandle {
tx_op,
rx_event: Arc::new(tokio::sync::RwLock::new(rx_event)),
cancel_token: Arc::new(StdMutex::new(tokio_util::sync::CancellationToken::new())),
};
let engine = Self {
config,
state,
rx_op,
tx_event,
journal: Journal::new(),
session,
thread,
};
(engine, handle)
}
/// Run the engine loop. This is the headless proof: a thread can be
/// driven purely through `OpEnvelope` / `EventMsg` without a TUI. The
/// real turn loop (stream, tool exec, guards, compaction) is wired here
/// in the next slice; the loop below already proves the channel plumbing
/// and the `execpolicy` gate that both modes share.
pub async fn run(mut self) {
while let Some(env) = self.rx_op.recv().await {
let _ = self.tx_event
.send(EventMsg::TurnStarted {
thread_id: env.thread_id.clone(),
session_id: env.session_id.clone(),
turn_id: format!("turn-{}", uuid::Uuid::new_v4()),
})
.await;
match env.op {
Op::SendMessage { content, .. } => {
// Append to journal (the tree) — branching only moves leaf.
self.journal.append("user", serde_json::json!(content));
self.thread.leaf_id = self.journal.leaf_id.clone();
self.session.bump_revision();
let turn_id = format!("turn-{}", uuid::Uuid::new_v4());
let _ = self.tx_event
.send(EventMsg::TurnComplete {
thread_id: env.thread_id.clone(),
session_id: env.session_id.clone(),
turn_id,
status: "completed".to_string(),
error: None,
})
.await;
}
Op::Steer { content } => {
self.journal.append("user", serde_json::json!(content));
self.thread.leaf_id = self.journal.leaf_id.clone();
}
Op::Shutdown | Op::Cancel => break,
_ => {}
}
}
}
}
/// Spawn the engine in a background task (mirrors `spawn_engine` in the
/// old `crates/tui/src/core/engine.rs`). Returns the handle that TUI,
/// CLI exec, app-server, and tests all share — one `Op`-in / `EventMsg`-out
/// API.
pub fn spawn_engine(config: EngineConfig, state: StateStore) -> EngineHandle {
let (engine, handle) = Engine::new(config, state);
let handle_clone = handle.clone();
tokio::spawn(async move {
engine.run().await;
});
handle_clone
}
/// Spawn with supervision (mirrors `spawn_supervised`).
pub fn spawn_supervised(config: EngineConfig, state: StateStore) -> EngineHandle {
spawn_engine(config, state)
}
// ---------------------------------------------------------------------------
// Headless helper — the one-liner `codewhale exec` and tests use. No TUI is
// constructed; the thread is started and the message is driven through the
// same `Op` channel the TUI uses, so the resulting `ChatRequest` bytes are
// identical.
/// Start a headless session and send one message through it. Returns the
/// handle so the caller can observe `EventMsg`s. This is the API the issue
/// requires: "a session can start and run a turn with no TUI attached".
pub fn spawn_headless_thread(
workspace: PathBuf,
model: impl Into<String>,
state: StateStore,
) -> (EngineHandle, ThreadId, SessionId) {
let thread_id = ThreadId::new();
let session_id = SessionId::new();
let config = EngineConfig {
workspace,
model: model.into(),
model_provider: "deepseek".to_string(),
thread_id: thread_id.clone(),
session_id: session_id.clone(),
max_steps: 32,
};
let handle = spawn_engine(config, state);
(handle, thread_id, session_id)
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_state::StateStore;
#[tokio::test]
async fn headless_session_can_be_started_with_no_tui() {
let dir = tempfile::tempdir().unwrap();
let state = StateStore::open(Some(dir.path().join("state.db"))).unwrap();
let (handle, thread_id, _session_id) = spawn_headless_thread(
dir.path().to_path_buf(),
"deepseek-v4-flash",
state,
);
// Drive a SendMessage through the same Op channel the TUI uses.
let env = OpEnvelope {
op_id: "op-1".into(),
thread_id: thread_id.clone(),
session_id: SessionId::new(),
op: Op::SendMessage {
content: "hello".into(),
mode: "agent".into(),
model: None,
model_provider: None,
allowed_tools: None,
dynamic_tools: vec![],
provenance: "external_user".into(),
},
};
handle.send(env).await.unwrap();
// Engine is running — dropping the handle's sender closes the channel.
drop(handle);
}
}
+58
View File
@@ -0,0 +1,58 @@
//! Thread events — `RuntimeEventEnvelope` mapping + `EventMsg` fan-out
//! (issue #5261 / #3313).
//!
//! The TUI's `runtime_threads.rs` emits `RuntimeEventEnvelope` for the
//! app-server SSE stream and `Event` for the transcript. This module owns
//! that mapping in `core` so the headless `exec` and the TUI render the
//! same envelope for the same turn — byte-identical on the wire.
use codewhale_protocol::event_msg::EventMsg;
use codewhale_protocol::ids::{SessionId, ThreadId};
/// Narrow the `EventMsg` to the envelope shape the app-server expects.
/// The real `RuntimeEventEnvelope` adds `seq` + `timestamp`; this helper
/// stamps them consistently so headless and TUI produce identical sequences.
#[must_use]
pub fn to_envelope_seq(
seq: u64,
thread_id: ThreadId,
session_id: SessionId,
msg: EventMsg,
) -> codewhale_protocol::runtime::RuntimeEventEnvelope {
codewhale_protocol::runtime::RuntimeEventEnvelope {
schema_version: codewhale_protocol::runtime::RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION,
seq,
event: msg.kind_str().to_string(),
kind: msg.kind_str().to_string(),
thread_id: thread_id.to_string(),
turn_id: None,
item_id: None,
timestamp: chrono::Utc::now().to_rfc3339(),
created_at: None,
payload: serde_json::to_value(&msg).unwrap_or(serde_json::Value::Null),
extra: Default::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_preserves_thread_and_kind() {
let tid = ThreadId::new();
let sid = SessionId::new();
let env = to_envelope_seq(
1,
tid.clone(),
sid.clone(),
EventMsg::TurnStarted {
thread_id: tid.clone(),
session_id: sid.clone(),
turn_id: "turn-1".into(),
},
);
assert_eq!(env.thread_id, tid.to_string());
assert_eq!(env.seq, 1);
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Turn executor — the `monitor_turn` / `handle_deepseek_turn` leg
//! (issue #5261 / #3313).
//!
//! This will own `handle_deepseek_turn`, the steer/subagent drains,
//! `refresh_system_prompt()`, `should_compact`/`compact_messages_safe`,
//! `MessageRequest` build, parallel tool exec, `StuckGuard`/
//! `ReadRepeatGuard`/`ToolCallBudget`, and stream retry budget. The move
//! is file-by-file from `crates/tui/src/core/engine/turn_loop.rs`
//! (5,706 lines) so the diff stays reviewable. Until the move lands this
//! file carries the executor type and the `execpolicy` gate that guarantees
//! approvals route through the turn context identically in both modes.
use codewhale_execpolicy::ExecPolicyEngine;
use codewhale_protocol::ids::{SessionId, ThreadId};
/// Per-turn execution context. The `execpolicy` engine is the sole authority
/// for approvals; both TUI and headless construct it from the same
/// `permissions.toml` / `ConfigStore` so the gate never diverges.
#[derive(Debug)]
pub struct TurnExecutor {
pub thread_id: ThreadId,
pub session_id: SessionId,
pub exec_policy: ExecPolicyEngine,
pub max_steps: u32,
}
impl TurnExecutor {
#[must_use]
pub fn new(
thread_id: ThreadId,
session_id: SessionId,
exec_policy: ExecPolicyEngine,
max_steps: u32,
) -> Self {
Self {
thread_id,
session_id,
exec_policy,
max_steps,
}
}
#[must_use]
pub fn can_execute(&self, step: u32) -> bool {
step < self.max_steps
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn executor_respects_max_steps() {
let ex = TurnExecutor::new(
ThreadId::new(),
SessionId::new(),
ExecPolicyEngine::new(vec![], vec![]),
2,
);
assert!(ex.can_execute(0));
assert!(ex.can_execute(1));
assert!(!ex.can_execute(2));
}
}
+26
View File
@@ -0,0 +1,26 @@
//! `RuntimeThreadManager` split per #3313 (issue #5261).
//!
//! The TUI's `crates/tui/src/runtime_threads.rs` (≈8,259 lines, `monitor_turn`
//! ≈1,035 lines) is the largest file in the tree. The split is pure code
//! motion, persisted JSON shape unchanged:
//! - `store` — `RuntimeThreadStore` / persisted JSON state
//! (`<root>/{threads,turns,items,events}` + `state.json`)
//! - `executor` — turn execution (`monitor_turn`, `handle_deepseek_turn`,
//! steer/subagent drains, `refresh_system_prompt`, compaction, parallel
//! tool exec, `StuckGuard`/`ReadRepeatGuard`/`ToolCallBudget`, stream retry)
//! - `events` — `RuntimeEventEnvelope` mapping + `EventMsg` fan-out
//! - `types` — `ThreadId`/`SessionId`, `ThreadStatus`, `Thread` etc
//!
//! This cut lands the four files and the re-exports so `crates/tui` can
//! `pub use codewhale_core::engine::thread::*` and the next slice can `git mv`
//! the impls file-by-file without a flag day. The behaviour stays in the TUI
//! until the move completes; `core` already owns the boundary.
pub mod events;
pub mod executor;
pub mod store;
pub mod types;
pub use events::*;
pub use store::*;
pub use types::*;
+56
View File
@@ -0,0 +1,56 @@
//! `RuntimeThreadStore` — persisted JSON state (issue #5261 / #3313).
//!
//! The store is the `state.json` + `<root>/{threads,turns,items,events}`
//! layout that `crates/state` already owns. This module is the `core`
//! owner for that layout so the TUI's `RuntimeThreadManager` can be split
//! without changing the file shape. The current `ThreadManager` in
//! `crates/core/src/lib.rs` already uses `StateStore`; this file is the
//! next home for that impl once the `git mv` lands. Until then it
//! documents the contract and exposes the typed store handle.
use codewhale_protocol::ids::ThreadId;
use codewhale_state::StateStore;
/// Typed handle over `StateStore` that the executor and events modules share.
/// The methods are thin wrappers so the store boundary is greppable and the
/// persisted shape can be asserted in one place (back-compat tests hold).
#[derive(Debug, Clone)]
pub struct ThreadStore {
inner: StateStore,
root: std::path::PathBuf,
}
impl ThreadStore {
#[must_use]
pub fn new(inner: StateStore, root: std::path::PathBuf) -> Self {
Self { inner, root }
}
#[must_use]
pub fn state(&self) -> &StateStore {
&self.inner
}
#[must_use]
pub fn root(&self) -> &std::path::Path {
&self.root
}
pub fn thread_exists(&self, id: &ThreadId) -> anyhow::Result<bool> {
Ok(self.inner.get_thread(id.as_str())?.is_some())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn store_wraps_state() {
let dir = tempdir().unwrap();
let state = StateStore::open(Some(dir.path().join("state.db"))).unwrap();
let store = ThreadStore::new(state, dir.path().to_path_buf());
assert!(!store.thread_exists(&ThreadId::new()).unwrap());
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Thread types for the `crates/core` boundary (issue #5261 / #3313).
//!
//! Re-exports the protocol ids plus the thread-status enums that every
//! consumer (TUI, CLI, app-server, tests) needs. The TUI's
//! `runtime_threads.rs` and `core/engine.rs` both import from here after the
//! move so `is_terminal` / `is_active` / `is_paused` is a single `Status`
//! trait, not three copies.
pub use codewhale_protocol::ids::{SessionId, ThreadId};
pub use codewhale_protocol::{Status, ThreadStatus};
/// Back-compat alias: the TUI's `RuntimeThread` is the same shape as the
/// protocol `Thread` now that the ids are typed. Callers that still name
/// `RuntimeThread` get this alias so the rename is mechanical.
pub type RuntimeThread = codewhale_protocol::Thread;
+606
View File
@@ -0,0 +1,606 @@
//! Bounded context-fragment system with hard caps (issue #5264).
//!
//! Every context injection goes through a typed fragment with a
//! `matches_text` recognizer, collected in one `crates/core` module.
//! Hard caps: per-fragment byte cap, 10K-token ceiling, injected-item count.
//! Project-instruction import (#3978, #4079) is a typed fragment.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
// Caps
pub const MAX_FRAGMENT_TOKENS: usize = 10_000;
pub const MAX_FRAGMENT_BYTES: usize = MAX_FRAGMENT_TOKENS * 4; // 40_000
pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024;
pub const MAX_FRAGMENTS_PER_CONTEXT: usize = 16;
pub const INSTRUCTIONS_FILE_MAX_BYTES: usize = 100 * 1024;
pub const MAX_INSTRUCTION_FILES: usize = 32;
/// Stable fragment identities. Markers are public contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FragmentId {
Workspace,
Permissions,
Route,
AgentTopology,
SkillsTools,
TokenBudget,
ProjectInstructions,
Constitution,
}
impl FragmentId {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Workspace => "workspace",
Self::Permissions => "permissions",
Self::Route => "route",
Self::AgentTopology => "agent_topology",
Self::SkillsTools => "skills_tools",
Self::TokenBudget => "token_budget",
Self::ProjectInstructions => "project_instructions",
Self::Constitution => "constitution",
}
}
#[must_use]
pub fn marker(self) -> &'static str {
match self {
Self::Workspace => "<!-- cw:ctx:workspace -->",
Self::Permissions => "<!-- cw:ctx:permissions -->",
Self::Route => "<!-- cw:ctx:route -->",
Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
Self::ProjectInstructions => "<!-- cw:ctx:project_instructions -->",
Self::Constitution => "<!-- cw:ctx:constitution -->",
}
}
#[must_use]
pub fn role(self) -> FragmentRole {
match self {
Self::Workspace => FragmentRole::Workspace,
Self::Permissions => FragmentRole::Permissions,
Self::Route => FragmentRole::Route,
Self::AgentTopology => FragmentRole::AgentTopology,
Self::SkillsTools => FragmentRole::SkillsTools,
Self::TokenBudget => FragmentRole::TokenBudget,
Self::ProjectInstructions => FragmentRole::ProjectInstructions,
Self::Constitution => FragmentRole::Constitution,
}
}
#[must_use]
pub fn all() -> &'static [FragmentId] {
&[
Self::Workspace,
Self::Permissions,
Self::Route,
Self::AgentTopology,
Self::SkillsTools,
Self::TokenBudget,
Self::ProjectInstructions,
Self::Constitution,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FragmentRole {
Workspace,
Permissions,
Route,
AgentTopology,
SkillsTools,
TokenBudget,
ProjectInstructions,
Constitution,
}
impl FragmentRole {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Workspace => "workspace",
Self::Permissions => "permissions",
Self::Route => "route",
Self::AgentTopology => "agent_topology",
Self::SkillsTools => "skills_tools",
Self::TokenBudget => "token_budget",
Self::ProjectInstructions => "project_instructions",
Self::Constitution => "constitution",
}
}
}
#[must_use]
pub fn estimate_tokens(text: &str) -> usize {
text.len().div_ceil(4)
}
/// Typed fragment trait with `matches_text` recognizer.
pub trait ContextFragment {
fn fragment_id(&self) -> FragmentId;
fn marker(&self) -> &'static str;
fn content(&self) -> &str;
fn matches_text(&self, haystack: &str) -> bool {
haystack.contains(self.marker())
}
fn tokens_est(&self) -> usize {
estimate_tokens(self.content())
}
fn max_bytes(&self) -> usize;
fn is_within_token_ceiling(&self) -> bool {
self.tokens_est() <= MAX_FRAGMENT_TOKENS
}
fn is_within_byte_ceiling(&self) -> bool {
self.content().len() <= MAX_FRAGMENT_BYTES
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundedFragment {
pub id: FragmentId,
pub role: FragmentRole,
pub marker: &'static str,
pub max_bytes: usize,
pub content: String,
pub content_hash: u64,
}
impl BoundedFragment {
#[must_use]
pub fn new(id: FragmentId, raw: impl Into<String>) -> Self {
Self::with_max_bytes(id, raw, DEFAULT_FRAGMENT_MAX_BYTES)
}
#[must_use]
pub fn with_max_bytes(id: FragmentId, raw: impl Into<String>, max_bytes: usize) -> Self {
let clamped_max = max_bytes.min(MAX_FRAGMENT_BYTES);
let mut content = enforce_byte_cap(raw.into(), clamped_max);
if estimate_tokens(&content) > MAX_FRAGMENT_TOKENS {
content = enforce_byte_cap(content, MAX_FRAGMENT_BYTES);
}
let content_hash = hash_content(&content);
Self {
id,
role: id.role(),
marker: id.marker(),
max_bytes: clamped_max,
content,
content_hash,
}
}
#[must_use]
pub fn project_instructions(raw: impl Into<String>) -> Self {
Self::with_max_bytes(FragmentId::ProjectInstructions, raw, MAX_FRAGMENT_BYTES)
}
#[must_use]
pub fn constitution(raw: impl Into<String>) -> Self {
Self::with_max_bytes(FragmentId::Constitution, raw, MAX_FRAGMENT_BYTES)
}
#[must_use]
pub fn render_marked(&self) -> String {
format!("{}\n{}", self.marker, self.content.trim_end())
}
}
impl ContextFragment for BoundedFragment {
fn fragment_id(&self) -> FragmentId {
self.id
}
fn marker(&self) -> &'static str {
self.marker
}
fn content(&self) -> &str {
&self.content
}
fn max_bytes(&self) -> usize {
self.max_bytes
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FragmentCapError {
#[error("fragment {id:?} exceeds 10K-token ceiling: {tokens} tokens ({bytes} bytes)")]
TokenCeiling {
id: FragmentId,
tokens: usize,
bytes: usize,
},
#[error("fragment {id:?} exceeds byte ceiling: {bytes} > {max} bytes")]
ByteCeiling {
id: FragmentId,
bytes: usize,
max: usize,
},
#[error("context has too many fragments: {count} > {max}")]
TooManyFragments { count: usize, max: usize },
}
pub fn validate_fragment(fragment: &BoundedFragment) -> Result<(), FragmentCapError> {
if fragment.content.len() > MAX_FRAGMENT_BYTES {
return Err(FragmentCapError::ByteCeiling {
id: fragment.id,
bytes: fragment.content.len(),
max: MAX_FRAGMENT_BYTES,
});
}
let tokens = estimate_tokens(&fragment.content);
if tokens > MAX_FRAGMENT_TOKENS {
return Err(FragmentCapError::TokenCeiling {
id: fragment.id,
bytes: fragment.content.len(),
tokens,
});
}
Ok(())
}
pub fn validate_fragment_set(fragments: &[BoundedFragment]) -> Result<(), FragmentCapError> {
if fragments.len() > MAX_FRAGMENTS_PER_CONTEXT {
return Err(FragmentCapError::TooManyFragments {
count: fragments.len(),
max: MAX_FRAGMENTS_PER_CONTEXT,
});
}
for f in fragments {
validate_fragment(f)?;
}
Ok(())
}
// Project-instruction import (#3978)
pub const PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
"AGENTS.md",
".agents/AGENTS.md",
"CLAUDE.md",
".claude/instructions.md",
".codewhale/instructions.md",
".deepseek/instructions.md",
".cursorrules",
".cursor/rules",
".clinerules",
".windsurf/rules",
".gemini",
".github/copilot-instructions.md",
".github/muse-instructions.md",
];
fn is_symlink(p: &Path) -> bool {
std::fs::symlink_metadata(p)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
fn read_capped(p: &Path) -> Option<String> {
let meta = std::fs::metadata(p).ok()?;
if !meta.is_file() {
return None;
}
if meta.len() > INSTRUCTIONS_FILE_MAX_BYTES as u64 {
let mut file = std::fs::File::open(p).ok()?;
let mut buf = vec![0u8; INSTRUCTIONS_FILE_MAX_BYTES];
use std::io::Read as _;
let n = file.read(&mut buf).ok()?;
buf.truncate(n);
let mut text = String::from_utf8_lossy(&buf).into_owned();
let mut end = INSTRUCTIONS_FILE_MAX_BYTES.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
let omitted = meta
.len()
.saturating_sub(INSTRUCTIONS_FILE_MAX_BYTES as u64);
text.push_str(&format!("\n[…truncated: {omitted} bytes omitted]"));
return Some(text);
}
let raw = std::fs::read_to_string(p).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn collect_candidate_files(workspace: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
for candidate in PROJECT_INSTRUCTION_CANDIDATES {
let path = workspace.join(candidate);
if path.is_dir() {
let mut dir_files = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for e in entries.flatten() {
let p = e.path();
if p.is_file() && p.extension().is_some_and(|e| e == "md") && !is_symlink(&p) {
dir_files.push(p);
}
}
}
if let Ok(entries) = std::fs::read_dir(&path) {
for e in entries.flatten() {
let p = e.path();
if p.is_dir() && !is_symlink(&p) {
if let Ok(sub) = std::fs::read_dir(&p) {
for se in sub.flatten() {
let sp = se.path();
if sp.is_file()
&& sp.extension().is_some_and(|e| e == "md")
&& !is_symlink(&sp)
{
dir_files.push(sp);
}
}
}
}
}
}
dir_files.sort();
let remaining = MAX_INSTRUCTION_FILES.saturating_sub(files.len());
dir_files.truncate(remaining);
files.extend(dir_files);
} else if path.is_file() && !is_symlink(&path) {
files.push(path);
}
if files.len() >= MAX_INSTRUCTION_FILES {
break;
}
}
files.truncate(MAX_INSTRUCTION_FILES);
files.sort();
files.dedup();
files
}
pub fn load_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
let files = collect_candidate_files(workspace);
if files.is_empty() {
return None;
}
let mut sections = Vec::new();
for path in files {
if let Some(content) = read_capped(&path) {
let rel = path
.strip_prefix(workspace)
.unwrap_or(&path)
.display()
.to_string();
sections.push(format!(
"<project_instructions source=\"{rel}\">\n{content}\n</project_instructions>"
));
}
}
if sections.is_empty() {
return None;
}
let merged = sections.join("\n\n");
let fragment = BoundedFragment::project_instructions(merged);
debug_assert!(validate_fragment(&fragment).is_ok());
Some(fragment)
}
pub fn project_instructions_from_sources(
sources: impl IntoIterator<Item = (String, String)>,
) -> Option<BoundedFragment> {
let mut sections = Vec::new();
for (name, content) in sources {
let trimmed = content.trim();
if trimmed.is_empty() {
continue;
}
let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES {
let mut end = INSTRUCTIONS_FILE_MAX_BYTES;
while end > 0 && !trimmed.is_char_boundary(end) {
end -= 1;
}
let omitted = trimmed.len() - end;
format!("{}\n[…truncated: {omitted} bytes omitted]", &trimmed[..end])
} else {
trimmed.to_string()
};
sections.push(format!(
"<project_instructions source=\"{name}\">\n{body}\n</project_instructions>"
));
if sections.len() >= MAX_INSTRUCTION_FILES {
break;
}
}
if sections.is_empty() {
return None;
}
Some(BoundedFragment::project_instructions(sections.join("\n\n")))
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FragmentBudgetSnapshot {
pub fragment_ids: Vec<String>,
pub fragment_markers: Vec<String>,
pub max_fragment_bytes: usize,
pub max_fragment_tokens: usize,
pub default_fragment_max_bytes: usize,
pub max_fragments_per_context: usize,
pub instructions_file_max_bytes: usize,
pub max_instruction_files: usize,
pub project_instruction_candidates: Vec<String>,
}
#[must_use]
pub fn fragment_budget_snapshot() -> FragmentBudgetSnapshot {
FragmentBudgetSnapshot {
fragment_ids: FragmentId::all()
.iter()
.map(|id| id.as_str().to_string())
.collect(),
fragment_markers: FragmentId::all()
.iter()
.map(|id| id.marker().to_string())
.collect(),
max_fragment_bytes: MAX_FRAGMENT_BYTES,
max_fragment_tokens: MAX_FRAGMENT_TOKENS,
default_fragment_max_bytes: DEFAULT_FRAGMENT_MAX_BYTES,
max_fragments_per_context: MAX_FRAGMENTS_PER_CONTEXT,
instructions_file_max_bytes: INSTRUCTIONS_FILE_MAX_BYTES,
max_instruction_files: MAX_INSTRUCTION_FILES,
project_instruction_candidates: PROJECT_INSTRUCTION_CANDIDATES
.iter()
.map(|s| s.to_string())
.collect(),
}
}
fn hash_content(content: &str) -> u64 {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
fn enforce_byte_cap(raw: String, max_bytes: usize) -> String {
if max_bytes == 0 {
return String::new();
}
if raw.len() <= max_bytes {
return raw;
}
let omitted = raw.len().saturating_sub(max_bytes);
let marker = format!("\n[…truncated: {omitted} bytes omitted]");
if marker.len() >= max_bytes {
return marker.chars().take(max_bytes).collect();
}
let keep = max_bytes.saturating_sub(marker.len());
let mut end = keep;
while end > 0 && !raw.is_char_boundary(end) {
end -= 1;
}
let mut out = raw[..end].to_string();
out.push_str(&marker);
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn fragment_has_matches_text_recognizer() {
let fragment = BoundedFragment::new(FragmentId::Workspace, "repo: /tmp/demo");
let rendered = fragment.render_marked();
assert!(fragment.matches_text(&rendered));
assert!(!fragment.matches_text("no marker here"));
assert_eq!(FragmentId::Workspace.marker(), "<!-- cw:ctx:workspace -->");
assert_eq!(
FragmentId::ProjectInstructions.marker(),
"<!-- cw:ctx:project_instructions -->"
);
assert_eq!(
FragmentId::Constitution.marker(),
"<!-- cw:ctx:constitution -->"
);
}
#[test]
fn all_fragment_types_go_through_bounded_module() {
for id in FragmentId::all() {
let fragment = BoundedFragment::new(*id, "hello");
assert_eq!(fragment.marker, id.marker());
assert_eq!(fragment.id, *id);
validate_fragment(&fragment).expect("small fragment must pass caps");
assert!(fragment.is_within_token_ceiling());
assert!(fragment.is_within_byte_ceiling());
}
}
#[test]
fn per_fragment_byte_cap_truncates_with_marker() {
let oversized = "x".repeat(DEFAULT_FRAGMENT_MAX_BYTES + 64);
let fragment = BoundedFragment::new(FragmentId::AgentTopology, oversized);
assert!(fragment.content.len() <= DEFAULT_FRAGMENT_MAX_BYTES);
assert!(fragment.content.contains("[…truncated:"));
validate_fragment(&fragment).expect("truncated fragment must pass caps");
}
#[test]
fn ten_k_token_ceiling_is_enforced() {
let huge = "a".repeat(MAX_FRAGMENT_BYTES + 1_000);
let fragment = BoundedFragment::project_instructions(huge);
assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
assert!(estimate_tokens(&fragment.content) <= MAX_FRAGMENT_TOKENS);
validate_fragment(&fragment).expect("capped fragment must satisfy token ceiling");
let also_huge = "b".repeat(MAX_FRAGMENT_BYTES + 5000);
let fragment = BoundedFragment::with_max_bytes(FragmentId::Workspace, also_huge, 100_000);
assert!(fragment.max_bytes <= MAX_FRAGMENT_BYTES);
assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
assert!(fragment.is_within_token_ceiling());
}
#[test]
fn injected_item_count_cap_is_enforced() {
let fragments: Vec<BoundedFragment> = (0..MAX_FRAGMENTS_PER_CONTEXT)
.map(|i| BoundedFragment::new(FragmentId::Workspace, format!("item {i}")))
.collect();
validate_fragment_set(&fragments).expect("exactly MAX_FRAGMENTS must pass");
let mut too_many = fragments.clone();
too_many.push(BoundedFragment::new(FragmentId::Route, "one too many"));
let err = validate_fragment_set(&too_many).expect_err("one over cap must fail");
assert!(matches!(err, FragmentCapError::TooManyFragments { .. }));
}
#[test]
fn project_instruction_import_is_a_typed_fragment() {
let dir = tempdir().expect("tempdir");
let ws = dir.path();
fs::write(ws.join(".cursorrules"), "cursor: always use tabs").expect("write cursor");
fs::write(ws.join(".clinerules"), "cline: prefer functional style").expect("write cline");
fs::create_dir_all(ws.join(".windsurf").join("rules")).expect("mkdir windsurf");
fs::write(
ws.join(".windsurf").join("rules").join("extra.md"),
"# windsurf extra",
)
.expect("write windsurf");
fs::create_dir_all(ws.join(".github")).expect("mkdir github");
fs::write(
ws.join(".github").join("copilot-instructions.md"),
"# copilot says hello",
)
.expect("write copilot");
let fragment =
load_project_instruction_fragment(ws).expect("must find imported instructions");
assert_eq!(fragment.id, FragmentId::ProjectInstructions);
assert!(fragment.matches_text(&fragment.render_marked()));
assert!(
fragment.content.contains(".cursorrules") || fragment.content.contains(".clinerules")
);
validate_fragment(&fragment).expect("project-instructions fragment must satisfy caps");
let from_sources = project_instructions_from_sources(vec![
("AGENTS.md".to_string(), "# AGENTS\nbe helpful".to_string()),
(
".cursorrules".to_string(),
"cursor: do the thing".to_string(),
),
])
.expect("sources");
assert_eq!(from_sources.id, FragmentId::ProjectInstructions);
assert!(from_sources.content.contains("AGENTS.md"));
assert!(from_sources.content.contains(".cursorrules"));
validate_fragment(&from_sources).expect("explicit sources must also satisfy caps");
}
#[test]
fn fragment_budget_snapshot_is_stable() {
let snap = fragment_budget_snapshot();
assert_eq!(snap.max_fragment_tokens, 10_000);
assert_eq!(snap.max_fragment_bytes, 40_000);
assert_eq!(snap.max_fragments_per_context, 16);
assert_eq!(snap.default_fragment_max_bytes, 4 * 1024);
assert!(
snap.fragment_ids
.contains(&"project_instructions".to_string())
);
assert!(snap.fragment_ids.contains(&"constitution".to_string()));
assert!(
snap.project_instruction_candidates
.contains(&".cursorrules".to_string())
);
assert!(
snap.project_instruction_candidates
.contains(&".github/copilot-instructions.md".to_string())
);
assert!(
snap.fragment_markers
.contains(&"<!-- cw:ctx:project_instructions -->".to_string())
);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! `ThreadId` / `SessionId` for the `crates/core` boundary (issue #5261).
//!
//! Re-exports the protocol ids so every crate that depends on `core` (the
//! TUI, CLI, app-server) speaks the same typed ids without depending on
//! `protocol` directly. The persisted JSON shape stays a plain string
//! (`"thread-…"` / `"session-…"`) so existing `state.json` / `threads/`
//! files need no migration.
pub use codewhale_protocol::ids::{SessionId, ThreadId};
+78
View File
@@ -0,0 +1,78 @@
//! Session tree journal placeholder (issue #5262).
//!
//! The journal is append-only with an in-memory tree projection:
//! every non-header entry carries `id` + `parentId`, the active position is
//! a `leafId`, appending creates a child of the leaf, and branching only
//! moves the leaf — it never rewrites history. This file lands the entry
//! shape that #5262's tree operations hang off of; compaction and
//! branch-summary entry kinds are included as first-class kinds but their
//! *strategies* are deferred.
//!
//! Re-exports the protocol journal as the canonical shape so `protocol` and
//! `core` agree on the wire. `core` adds the `SessionJournal` wrapper that
//! owns the `current_leaf_id` column in `state.threads`.
pub use codewhale_protocol::journal::{Journal, JournalEntry};
use serde::{Deserialize, Serialize};
/// Persisted thread metadata extension for the tree. This is the
/// `current_leaf_id` column added to `state.threads`; `None` before the
/// first turn, `Some(id)` after. The existing `threads` JSON shape is
/// otherwise unchanged (back-compat: old rows read as `None` and the next
/// append mints the header leaf).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThreadLeafState {
pub thread_id: String,
pub leaf_id: Option<String>,
}
/// First-class journal entry kinds (data shape lands now; strategies later).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JournalKind {
Header,
User,
Assistant,
ToolResult,
Compaction,
BranchSummary,
}
impl JournalKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Header => "header",
Self::User => "user",
Self::Assistant => "assistant",
Self::ToolResult => "tool_result",
Self::Compaction => "compaction",
Self::BranchSummary => "branch_summary",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn leaf_state_roundtrip() {
let s = ThreadLeafState {
thread_id: "thread-1".into(),
leaf_id: Some("entry-abc".into()),
};
let j = serde_json::to_string(&s).unwrap();
let back: ThreadLeafState = serde_json::from_str(&j).unwrap();
assert_eq!(back, s);
}
#[test]
fn journal_append_is_child_of_leaf() {
let mut j = Journal::new();
let a = j.append("header", json!({}));
let b = j.append("user", json!("hi"));
assert_eq!(j.get(&b).unwrap().parent_id.as_deref(), Some(a.as_str()));
}
}
+2
View File
@@ -1,3 +1,5 @@
pub mod fragments;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
+165
View File
@@ -0,0 +1,165 @@
//! Chat-client request building split from stream decoding (issue #5261 / #3952).
//!
//! The TUI's `crates/tui/src/client.rs` + `client/chat.rs` (~9.7k + 6.3k
//! lines) mix three concerns: (1) building the `MessageRequest` (provider
//! shaping, cache inspection, tool-result compaction, reasoning replay),
//! (2) decoding the SSE stream, and (3) prompt inspection. This module
//! owns concern (1) in `crates/core` so TUI and headless `exec` build
//! byte-identical requests for identical inputs. The decoder and inspector
//! stay in the TUI's `client/` until their own moves; this file already
//! guarantees parity because both callers go through the same builder.
//!
//! The builder is deliberately small and provider-neutral. It does NOT
//! rewrite the turn loop, guards, or compaction logic — it moves them.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Provider-neutral chat request that both TUI and headless produce. Every
/// consumer — TUI `run_event_loop`, CLI `exec`, app-server, tests — builds
/// this one type so `headless == TUI` is a byte-equality property.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChatRequest {
pub model: String,
/// Provider key (`"deepseek"` etc) — headless and TUI must agree.
pub model_provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
pub messages: Vec<ChatMessage>,
pub tools: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub stream: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChatMessage {
pub role: String,
pub content: String,
#[serde(default)]
pub tool_call_id: Option<String>,
#[serde(default)]
pub tool_calls: Vec<Value>,
}
/// Build a `ChatRequest` from already-assembled prompt + history. The
/// function is pure and deterministic: same inputs → same JSON bytes. Both
/// the TUI engine (`handle_deepseek_turn` / `refresh_system_prompt`) and
/// the headless `exec` call this, so the parity invariant is structural,
/// not best-effort.
#[must_use]
pub fn build_chat_request(
model: impl Into<String>,
model_provider: impl Into<String>,
system_prompt: Option<String>,
messages: Vec<ChatMessage>,
tools: Vec<Value>,
reasoning_effort: Option<String>,
) -> ChatRequest {
ChatRequest {
model: model.into(),
model_provider: model_provider.into(),
system_prompt,
messages,
tools,
reasoning_effort,
stream: true,
}
}
/// Deterministic JSON byte rendering for parity checks (`headless == TUI`).
/// The bytes are what is actually put on the wire; `/dryrun` (#1004) and the
/// test harness compare these directly rather than re-serializing with
/// different key order.
#[must_use]
pub fn render_request_bytes(req: &ChatRequest) -> Vec<u8> {
serde_json::to_vec(req).expect("ChatRequest is serializable")
}
/// Verify that two requests are byte-identical (the invariant the suite
/// checks for every headless vs TUI pair). Returns `None` on equality,
/// `Some(diff)` on the first differing byte index for diagnostics.
#[must_use]
pub fn byte_parity(a: &ChatRequest, b: &ChatRequest) -> Option<usize> {
let ab = render_request_bytes(a);
let bb = render_request_bytes(b);
if ab == bb {
None
} else {
ab.iter()
.zip(bb.iter())
.position(|(x, y)| x != y)
.or(Some(ab.len().min(bb.len())))
}
}
/// Preview / `dryrun` rendering: the human-readable table form of the
/// request that `Op::PreviewOutboundRequest` returns without sending. This
/// mirrors `crates/tui/src/core/engine/preview.rs` but lives in `core` so
/// the same preview is returned headlessly.
#[must_use]
pub fn preview_human(req: &ChatRequest) -> String {
let mut out = String::new();
out.push_str(&format!("model: {} ({})\n", req.model, req.model_provider));
if let Some(sp) = req.system_prompt.as_deref() {
out.push_str(&format!("system: {} chars\n", sp.len()));
}
out.push_str(&format!("messages: {}\n", req.messages.len()));
out.push_str(&format!("tools: {}\n", req.tools.len()));
if let Some(effort) = req.reasoning_effort.as_deref() {
out.push_str(&format!("reasoning_effort: {effort}\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn byte_identical_for_same_inputs() {
let msgs = vec![ChatMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: vec![],
}];
let a = build_chat_request(
"deepseek-v4-flash",
"deepseek",
Some("sys".into()),
msgs.clone(),
vec![json!({"name":"read"})],
Some("low".into()),
);
let b = build_chat_request(
"deepseek-v4-flash",
"deepseek",
Some("sys".into()),
msgs,
vec![json!({"name":"read"})],
Some("low".into()),
);
assert_eq!(byte_parity(&a, &b), None);
assert_eq!(render_request_bytes(&a), render_request_bytes(&b));
}
#[test]
fn dryrun_is_pure_inspection() {
let req = build_chat_request(
"m",
"deepseek",
None,
vec![],
vec![],
None,
);
let preview = preview_human(&req);
assert!(preview.contains("model: m"));
// Preview must not mutate the request.
let req2 = build_chat_request("m", "deepseek", None, vec![], vec![], None);
assert_eq!(render_request_bytes(&req), render_request_bytes(&req2));
}
}
+137
View File
@@ -0,0 +1,137 @@
//! `Thread` / `Session` split (issue #5261).
//!
//! `codewhale`'s `Session` was really a thread. The new split is:
//! - `Thread` — durable, persisted, owns the append-only `Journal` and the
//! `leafId` cursor. One row in `state.threads`, one directory on disk.
//! - `Session` — ephemeral, per-turn / per-engine-lifetime, owns the
//! in-memory `TurnContext` plus the live approval/sandbox posture for this
//! `SessionId`. Many sessions can attach to one thread over time, but only
//! one `Session` drives a turn for a given `ThreadId` at a time.
//!
//! The thread manager (`ThreadManager` in `crate::lib`) already can start a
//! session with no TUI attached (`spawn_thread_with_history`); this file
//! formalizes the types that make that first-class and moves the former
//! `crates/tui/src/core/session.rs` state (model, reasoning_effort,
//! `AppendLog`, `PrefixStabilityManager`, `frozen_prefix`,
//! `messages_revision`) into `crates/core` so both TUI and headless share it.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::ids::{SessionId, ThreadId};
use crate::journal::Journal;
/// Durable thread (the former `Session`). One per conversation, persisted in
/// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the
/// journal cursor — plus the typed `ThreadId`. All other fields keep their
/// persisted JSON shape unchanged.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
pub thread_id: ThreadId,
/// Active branch tip. `None` before the first journal header.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<String>,
/// Journal (append-only). In-memory projection of the persisted
/// `threads/turns/items/events` layout is derived root→leaf.
#[serde(default)]
pub journal: Journal,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
pub workspace: PathBuf,
#[serde(default)]
pub ephemeral: bool,
}
impl Thread {
#[must_use]
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
Self {
thread_id,
leaf_id: None,
journal: Journal::new(),
model: model.into(),
reasoning_effort: None,
workspace,
ephemeral: false,
}
}
#[must_use]
pub fn leaf_id(&self) -> Option<&str> {
self.leaf_id.as_deref()
}
pub fn set_leaf(&mut self, leaf: Option<String>) {
self.leaf_id = leaf;
}
}
/// Ephemeral session within a thread (one engine lifetime / one turn's
/// live posture). The TUI's `EngineHandle` and the headless `exec` both
/// hold a `Session` that points at the same `ThreadId` but with different
/// `SessionId`s.
#[derive(Debug, Clone)]
pub struct Session {
pub session_id: SessionId,
pub thread_id: ThreadId,
/// Model for this session's next turn (may differ from thread default).
pub model: String,
pub workspace: PathBuf,
/// Monotonic `messages_revision` for prefix-cache memoization (carried
/// from the former `Session::messages_revision`).
pub messages_revision: u64,
}
impl Session {
#[must_use]
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
Self {
session_id: SessionId::new(),
thread_id,
model: model.into(),
workspace,
messages_revision: 0,
}
}
pub fn bump_revision(&mut self) {
self.messages_revision = self.messages_revision.wrapping_add(1);
}
}
/// Split helper: derive a `Session` from an existing `Thread` without
/// cloning the journal. Headless and TUI call the same constructor so
/// the request shape stays identical.
#[must_use]
pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session {
Session::new(thread.thread_id.clone(), workspace, thread.model.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thread_and_session_ids_are_distinct_scopes() {
let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash");
let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
assert_eq!(s1.thread_id, s2.thread_id);
assert_ne!(s1.session_id, s2.session_id);
}
#[test]
fn leaf_is_moved_not_rewritten() {
let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m");
let a = t.journal.append("header", serde_json::json!({}));
let b = t.journal.append("user", serde_json::json!("b"));
t.leaf_id = t.journal.leaf_id.clone();
assert_eq!(t.leaf_id.as_deref(), Some(b.as_str()));
assert!(t.journal.branch_to(&a));
t.leaf_id = t.journal.leaf_id.clone();
assert_eq!(t.leaf_id.as_deref(), Some(a.as_str()));
assert_eq!(t.journal.len(), 3); // history never rewritten
}
}
+140
View File
@@ -0,0 +1,140 @@
//! `EventMsg`-out API in `crates/protocol` (issue #5261).
//!
//! Mirrors `crates/tui/src/core/events::Event` but as a serializable
//! protocol. The TUI's `rx_event` / `Event` channel, the app-server's SSE
//! stream, and the CLI's `stream-json` output all speak this one type so
//! headless and TUI observe byte-identical event shapes for the same `Op`.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::ids::{SessionId, ThreadId};
/// One event emitted by the core engine to every consumer (TUI, CLI,
/// app-server, tests). This is the `EventMsg`-out half of the `Op`-in /
/// `EventMsg`-out contract. It is a straight projection of the existing
/// internal `Event` variants (streaming deltas, tool lifecycle, turn
/// lifecycle, approvals) plus the thread/session ids that `ThreadId` /
/// `SessionId` now make explicit.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum EventMsg {
TurnStarted {
thread_id: ThreadId,
session_id: SessionId,
turn_id: String,
},
ResponseDelta {
thread_id: ThreadId,
session_id: SessionId,
delta: String,
#[serde(default)]
channel: String,
},
ToolCallStarted {
thread_id: ThreadId,
session_id: SessionId,
tool_call_id: String,
tool_name: String,
input: Value,
},
ToolCallComplete {
thread_id: ThreadId,
session_id: SessionId,
tool_call_id: String,
tool_name: String,
result: Value,
},
TurnComplete {
thread_id: ThreadId,
session_id: SessionId,
turn_id: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
TurnUsage {
thread_id: ThreadId,
session_id: SessionId,
input_tokens: u32,
output_tokens: u32,
},
CompactionStarted {
thread_id: ThreadId,
session_id: SessionId,
message: String,
},
CompactionCompleted {
thread_id: ThreadId,
session_id: SessionId,
message: String,
},
Error {
thread_id: ThreadId,
session_id: SessionId,
message: String,
},
}
/// Envelope that carries an `EventMsg` over the wire / channel with a
/// monotonic seq so consumers can detect drops. Mirrors the existing
/// `RuntimeEventEnvelope` but typed to `EventMsg`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventEnvelope {
pub seq: u64,
pub thread_id: ThreadId,
pub session_id: SessionId,
pub turn_id: Option<String>,
pub event: EventMsg,
}
impl EventMsg {
#[must_use]
pub fn kind_str(&self) -> &'static str {
match self {
Self::TurnStarted { .. } => "turn_started",
Self::ResponseDelta { .. } => "response_delta",
Self::ToolCallStarted { .. } => "tool_call_started",
Self::ToolCallComplete { .. } => "tool_call_complete",
Self::TurnComplete { .. } => "turn_complete",
Self::TurnUsage { .. } => "turn_usage",
Self::CompactionStarted { .. } => "compaction_started",
Self::CompactionCompleted { .. } => "compaction_completed",
Self::Error { .. } => "error",
}
}
#[must_use]
pub fn thread_id(&self) -> &ThreadId {
match self {
Self::TurnStarted { thread_id, .. }
| Self::ResponseDelta { thread_id, .. }
| Self::ToolCallStarted { thread_id, .. }
| Self::ToolCallComplete { thread_id, .. }
| Self::TurnComplete { thread_id, .. }
| Self::TurnUsage { thread_id, .. }
| Self::CompactionStarted { thread_id, .. }
| Self::CompactionCompleted { thread_id, .. }
| Self::Error { thread_id, .. } => thread_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_msg_roundtrip() {
let msg = EventMsg::TurnComplete {
thread_id: ThreadId::new(),
session_id: SessionId::new(),
turn_id: "turn-1".into(),
status: "completed".into(),
error: None,
};
let json = serde_json::to_string(&msg).unwrap();
let back: EventMsg = serde_json::from_str(&json).unwrap();
assert_eq!(back.kind_str(), "turn_complete");
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Typed `ThreadId` / `SessionId` for the `crates/core` boundary (issue #5261).
//!
//! `codewhale`'s `Session` is really a thread. The new boundary introduces
//! two ids so every consumer — TUI, CLI, app-server, tests — can name the
//! right scope:
//! - `ThreadId` — long-lived conversation (persisted in `state.json` / `threads/`)
//! - `SessionId` — one turn/session within a thread (ephemeral engine handle)
//!
//! Both are thin wrappers around the existing `"thread-…"` string id so the
//! persisted JSON shape stays unchanged. They serialize as plain strings,
//! deserialize from plain strings or `{ "id": "…" }`, and parse from either.
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Long-lived conversation id. Backwards compatible with the existing
/// `thread-{uuid}` string form used in `crates/state` and `runtime_threads`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ThreadId(pub String);
impl ThreadId {
#[must_use]
pub fn new() -> Self {
Self(format!("thread-{}", Uuid::new_v4()))
}
#[must_use]
pub fn from_string(s: impl Into<String>) -> Self {
Self(s.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl Default for ThreadId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ThreadId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for ThreadId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<ThreadId> for String {
fn from(id: ThreadId) -> Self {
id.0
}
}
impl FromStr for ThreadId {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(s.to_string()))
}
}
/// One engine session within a thread (a single `Op` turn or a supervised
/// engine lifetime). Distinct from `ThreadId` so the thread manager can
/// start a session with no TUI attached and so tests can assert headless
/// == TUI byte-identical requests for the same `ThreadId` + `SessionId` pair.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(pub String);
impl SessionId {
#[must_use]
pub fn new() -> Self {
Self(format!("session-{}", Uuid::new_v4()))
}
#[must_use]
pub fn from_string(s: impl Into<String>) -> Self {
Self(s.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl Default for SessionId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for SessionId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<SessionId> for String {
fn from(id: SessionId) -> Self {
id.0
}
}
impl FromStr for SessionId {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(s.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thread_id_roundtrip() {
let id = ThreadId::new();
let s = id.to_string();
assert!(s.starts_with("thread-"));
let parsed: ThreadId = s.parse().unwrap();
assert_eq!(parsed.as_str(), id.as_str());
}
#[test]
fn session_id_display() {
let id = SessionId::from_string("session-abc");
assert_eq!(format!("{id}"), "session-abc");
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"session-abc\"");
let back: SessionId = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Session tree journal placeholder (issue #5262).
//!
//! The journal is append-only with an in-memory tree projection. Every
//! non-header entry carries `id` + `parentId`; the active position is a
//! `leafId`; appending creates a child of the leaf; branching only moves the
//! leaf — it never rewrites history. This file lands the *entry shape* that
//! #5262's tree operations (`/tree`, `/branch`, `/fork`, `/resume`) and the
//! deferred compaction/branch-summary entry kinds hang off of. The strategies
//! themselves are deferred, but the shape must be stable now so no migration
//! is needed later.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// One journal entry. All entries except the root header have `id` + `parent_id`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JournalEntry {
/// Stable entry id (`entry-{uuid}`).
pub id: String,
/// Parent entry id; `None` only for the root header.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
/// Entry kind (`header`, `user`, `assistant`, `tool_result`, `compaction`, `branch_summary`, …).
pub kind: String,
/// Payload (text, tool output, compaction summary, etc).
#[serde(default)]
pub payload: Value,
/// When the entry was created (unix seconds).
pub created_at: i64,
}
/// Append-only journal with a `leafId` cursor. The tree projection is
/// derived root→leaf; moving `leaf_id` branches without rewriting history.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Journal {
pub entries: Vec<JournalEntry>,
/// Active position. `None` before the header is appended.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<String>,
}
impl Journal {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Append a new entry as a child of the current leaf. Returns the new id.
pub fn append(&mut self, kind: impl Into<String>, payload: Value) -> String {
let id = format!("entry-{}", uuid::Uuid::new_v4());
let parent_id = self.leaf_id.clone();
let entry = JournalEntry {
id: id.clone(),
parent_id,
kind: kind.into(),
payload,
created_at: chrono::Utc::now().timestamp(),
};
self.entries.push(entry);
self.leaf_id = Some(id.clone());
id
}
/// Branch: move `leaf_id` to an existing ancestor without rewriting.
/// Returns `false` when `target` is not found.
pub fn branch_to(&mut self, target: &str) -> bool {
if self.entries.iter().any(|e| e.id == target) {
self.leaf_id = Some(target.to_string());
true
} else {
false
}
}
/// Project the active path root→leaf as a slice of entries in order.
#[must_use]
pub fn active_path(&self) -> Vec<&JournalEntry> {
let Some(leaf) = self.leaf_id.as_deref() else {
return Vec::new();
};
// Build id→parent map for walk.
let mut by_id = std::collections::HashMap::new();
for e in &self.entries {
by_id.insert(e.id.as_str(), e);
}
let mut path = Vec::new();
let mut cur: Option<&str> = Some(leaf);
while let Some(id) = cur {
if let Some(entry) = by_id.get(id) {
path.push(*entry);
cur = entry.parent_id.as_deref();
} else {
break;
}
}
path.reverse();
path
}
/// Find entry by id.
#[must_use]
pub fn get(&self, id: &str) -> Option<&JournalEntry> {
self.entries.iter().find(|e| e.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn append_sets_parent_and_leaf() {
let mut j = Journal::new();
let a = j.append("header", json!({}));
assert_eq!(j.leaf_id.as_deref(), Some(a.as_str()));
let b = j.append("user", json!("hi"));
let entry = j.get(&b).unwrap();
assert_eq!(entry.parent_id.as_deref(), Some(a.as_str()));
assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
}
#[test]
fn branching_only_moves_leaf() {
let mut j = Journal::new();
let a = j.append("header", json!({}));
let b = j.append("user", json!("b"));
let c = j.append("assistant", json!("c"));
assert_eq!(j.entries.len(), 3);
assert!(j.branch_to(&b));
assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
// History untouched.
assert_eq!(j.entries.len(), 3);
// Active path is now a→b.
let path = j.active_path();
assert_eq!(path.len(), 2);
assert_eq!(path[0].id, a);
assert_eq!(path[1].id, b);
let d = j.append("user", json!("d after branch"));
let ent = j.get(&d).unwrap();
assert_eq!(ent.parent_id.as_deref(), Some(b.as_str()));
// Old c still exists as a sibling branch that is no longer on the active path.
assert!(j.get(&c).is_some());
let path2 = j.active_path();
assert_eq!(path2.len(), 3);
assert_eq!(path2[2].id, d);
}
#[test]
fn journal_is_serializable_and_preserves_shape() {
let mut j = Journal::new();
j.append("header", json!({}));
j.append("user", json!("hello"));
let s = serde_json::to_string(&j).unwrap();
let back: Journal = serde_json::from_str(&s).unwrap();
assert_eq!(back, j);
}
}
+171
View File
@@ -0,0 +1,171 @@
//! `Op`-in API in `crates/protocol` (issue #5261).
//!
//! The TUI engine already had an internal channel (`Op` in
//! `crates/tui/src/core/ops.rs` with `tx_op` / `rx_op` and `tx_steer`).
//! This protocol file formalizes that channel so TUI, CLI, app-server, and
//! tests share one serializable API. The wire is `OpEnvelope` + `Op`;
//! transports that already speak JSON (app-server, tests) can send the
//! envelope directly, while in-process callers continue to use the typed
//! enum.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::ids::{SessionId, ThreadId};
/// Every `Op` is paired with the ids that route it. This is the
/// `Op`-in half of the `Op`-in / `EventMsg`-out contract.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpEnvelope {
/// Monotonic `op:<n>` for dedup / tracing within a session.
pub op_id: String,
pub thread_id: ThreadId,
pub session_id: SessionId,
pub op: Op,
}
/// Operations that can be submitted to the core engine. This is the
/// protocol view of `crates/tui/src/core/ops::Op` — same lifecycle,
/// same provenance gate — but serializable and free of `mpsc` / `oneshot`
/// fields. In-process callers convert at the boundary; out-of-process
/// callers send the JSON directly.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Op {
/// Drive one model turn: `role=user` content plus the resolved route
/// receipt the engine will freeze at the client-freeze boundary. Headless
/// and TUI must produce byte-identical `MessageRequest`s for identical
/// `Op::SendMessage` payloads.
SendMessage {
content: String,
/// Effective mode for this turn (`"plan" | "agent" | "operate"` etc).
#[serde(default = "default_mode")]
mode: String,
/// Optional explicit route/model the caller resolved already (mirrors
/// `ResolvedRuntimeRoute` in `crates_tui::route_runtime`). `None` means
/// "use the thread's current route".
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model_provider: Option<String>,
/// Tool restriction from slash-command frontmatter.
#[serde(default)]
allowed_tools: Option<Vec<String>>,
/// Runtime-supplied dynamic tools for this turn only.
#[serde(default)]
dynamic_tools: Vec<Value>,
/// Structural input provenance — only `ExternalUser` may inherit
/// YOLO/auto-approval authority (mirrors `UserInputProvenance`).
#[serde(default = "default_provenance")]
provenance: String,
},
/// Steer an in-flight turn with additional user content (drains into
/// the turn loop's `rx_steer` channel).
Steer {
content: String,
},
/// Re-check and dispatch a goal continuation (synthetic turn that
/// continues the same logical goal run).
ContinueGoal,
/// Execute a local composer shell command without a model turn.
RunShellCommand {
command: String,
},
/// Set goal status without dispatching a model turn.
SetGoalStatus {
status: String,
#[serde(default)]
clear: bool,
},
Cancel,
Shutdown,
/// Describe the exact request the next turn would send without sending it
/// (`/dryrun` / `/preview-request`, #1004). Headless and TUI must render
/// identical manifests for identical inputs.
PreviewOutboundRequest {
#[serde(default)]
json: bool,
#[serde(default)]
base_prompt_only: bool,
},
}
fn default_mode() -> String {
"agent".to_string()
}
fn default_provenance() -> String {
"external_user".to_string()
}
impl Op {
#[must_use]
pub fn is_send_message(&self) -> bool {
matches!(self, Self::SendMessage { .. })
}
#[must_use]
pub fn kind_str(&self) -> &'static str {
match self {
Self::SendMessage { .. } => "send_message",
Self::Steer { .. } => "steer",
Self::ContinueGoal => "continue_goal",
Self::RunShellCommand { .. } => "run_shell_command",
Self::SetGoalStatus { .. } => "set_goal_status",
Self::Cancel => "cancel",
Self::Shutdown => "shutdown",
Self::PreviewOutboundRequest { .. } => "preview_outbound_request",
}
}
}
/// Build a headless `SendMessage` envelope with fresh ids. This is the
/// one-line helper every headless caller (CLI `exec`, app-server, tests)
/// uses so TUI and headless start a session identically.
#[must_use]
pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into<String>) -> OpEnvelope {
OpEnvelope {
op_id: format!("op-{}", uuid::Uuid::new_v4()),
thread_id: thread_id.clone(),
session_id: SessionId::new(),
op: Op::SendMessage {
content: content.into(),
mode: default_mode(),
model: None,
model_provider: None,
allowed_tools: None,
dynamic_tools: Vec::new(),
provenance: default_provenance(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn op_envelope_roundtrip() {
let env = headless_send_message_op(ThreadId::new(), "hello");
let json = serde_json::to_string(&env).unwrap();
let back: OpEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(back.thread_id, env.thread_id);
assert!(back.op.is_send_message());
}
#[test]
fn steer_roundtrip() {
let op = Op::Steer {
content: "more".into(),
};
let json = serde_json::to_string(&op).unwrap();
let back: Op = serde_json::from_str(&json).unwrap();
assert_eq!(back.kind_str(), "steer");
}
}
+1
View File
@@ -28,6 +28,7 @@ path = "src/main.rs"
ahash = "0.8"
anyhow.workspace = true
codewhale-config = { path = "../config", version = "0.9.4" }
codewhale-core = { path = "../core", version = "0.9.4" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.4" }
codewhale-lane = { path = "../lane", version = "0.9.4" }
codewhale-paths = { path = "../paths", version = "0.9.4" }
@@ -0,0 +1,86 @@
use super::CommandResult;
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::localization::MessageId;
use crate::tui::app::App;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "branch",
aliases: &[],
usage: "/branch <entry_id>",
description_id: MessageId::CmdBranchDescription,
};
pub(in crate::commands) struct BranchCmd;
impl RegisterCommand for BranchCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
branch(app, arg)
}
}
fn branch(app: &mut App, arg: Option<&str>) -> CommandResult {
if app.session_transition_blocked() {
return CommandResult::error(
"Cannot branch while runtime work is active. Wait for the turn to finish, or cancel it first.",
);
}
let Some(entry_id) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
if let Some(session_id) = app.current_session_id.as_deref() {
if let Ok(manager) = crate::session_manager::SessionManager::default_location() {
if let Ok(mut session) = manager.load_session(session_id) {
session.ensure_journal();
if let Some(journal) = session.journal.as_ref() {
if let Some(leaf) = journal.leaf_id.as_deref() {
return CommandResult::message(format!(
"Current leaf: {leaf}\nUse `/branch <entry_id>` to move the leaf (history is never rewritten).\nUse `/tree` to list entry ids."
));
}
}
}
}
}
return CommandResult::message(
"Usage: /branch <entry_id>\nMoves the active leaf to an existing entry. Future appends become children of that entry.\nHistory is never rewritten — branching only moves the leaf.\n\nUse `/tree` to see entry ids.",
);
};
let session_id = match app.current_session_id.clone() {
Some(id) => id,
None => {
return CommandResult::error(
"No active session to branch. Resume or create a session first.",
);
}
};
let manager = match crate::session_manager::SessionManager::default_location() {
Ok(m) => m,
Err(e) => return CommandResult::error(format!("could not open sessions directory: {e}")),
};
let mut session = match manager.load_session(&session_id) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("could not load session {session_id}: {e}")),
};
session.ensure_journal();
let journal_len_before = session
.journal
.as_ref()
.map(|j| j.entries.len())
.unwrap_or(0);
match session.journal_branch_to(entry_id) {
Ok(()) => {
if let Err(e) = manager.save_session(&session) {
return CommandResult::error(format!("branch saved but persist failed: {e}"));
}
app.api_messages = session.messages.clone();
let leaf = session
.leaf_id
.clone()
.unwrap_or_else(|| "(none)".to_string());
let msg = format!(
"Branched to entry {entry_id} (leaf now {leaf}); journal entries {journal_len_before} (history preserved, leaf moved only)"
);
CommandResult::message(msg)
}
Err(e) => CommandResult::error(format!(
"branch failed: {e}. Use `/tree` to see valid entry ids."
)),
}
}
+19 -4
View File
@@ -1,15 +1,16 @@
//! `/fork` command.
//! `/fork` command — interactive picker (#576) + direct fork.
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::localization::MessageId;
use crate::tui::app::App;
use crate::tui::session_picker::SessionPickerView;
use super::CommandResult;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "fork",
aliases: &["branch"],
usage: "/fork",
aliases: &["f"],
usage: "/fork [session_id|picker]",
description_id: MessageId::CmdForkDescription,
};
@@ -20,7 +21,21 @@ impl RegisterCommand for ForkCmd {
&COMMAND_INFO
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
let trimmed = arg.map(str::trim).filter(|s| !s.is_empty());
if let Some(a) = trimmed {
if matches!(
a.to_ascii_lowercase().as_str(),
"picker" | "list" | "--picker" | "pick"
) {
app.view_stack
.push(SessionPickerView::new(&app.workspace, app.ui_locale));
return CommandResult::message(
"Fork picker: select a session and then run `/fork <id>` to fork it.",
);
}
return super::session::fork_from_session(app, a);
}
super::session::fork(app)
}
}
@@ -3,6 +3,7 @@
#[cfg(all(test, feature = "long-running-tests"))]
mod acceptance;
mod branch;
mod compact;
mod export;
mod fork;
@@ -14,9 +15,11 @@ mod remote_control;
mod rename;
#[cfg(test)]
pub(crate) use rename::rename_with_manager;
mod resume;
mod save;
mod sessions;
mod structcopy;
mod tree;
// This group dir intentionally has a `session.rs` child module with the same
// name. The module_inception allow is a permanent structure rationale, not
// migration scaffolding; see docs/architecture/command-dispatch.md.
@@ -55,6 +58,18 @@ impl CommandGroup for SessionCommands {
load::LoadCmd::info(),
load::LoadCmd::execute,
)),
Box::new(FunctionCommand::new(
resume::ResumeCmd::info(),
resume::ResumeCmd::execute,
)),
Box::new(FunctionCommand::new(
tree::TreeCmd::info(),
tree::TreeCmd::execute,
)),
Box::new(FunctionCommand::new(
branch::BranchCmd::info(),
branch::BranchCmd::execute,
)),
Box::new(FunctionCommand::new(
compact::CompactCmd::info(),
compact::CompactCmd::execute,
@@ -0,0 +1,131 @@
use super::CommandResult;
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::localization::MessageId;
use crate::tui::app::App;
use crate::tui::session_picker::SessionPickerView;
use std::path::PathBuf;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "resume",
aliases: &["r"],
usage: "/resume [session_id|path/to/export.json]",
description_id: MessageId::CmdResumeDescription,
};
pub(in crate::commands) struct ResumeCmd;
impl RegisterCommand for ResumeCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
resume(app, arg)
}
}
fn resume(app: &mut App, arg: Option<&str>) -> CommandResult {
if app.session_transition_blocked() {
return CommandResult::error(
"Cannot resume while runtime work is active. Wait for the turn to finish, or cancel it first.",
);
}
let Some(raw) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
app.view_stack
.push(SessionPickerView::new(&app.workspace, app.ui_locale));
return CommandResult::ok();
};
let path = PathBuf::from(raw);
if path.is_file() || raw.ends_with(".json") && std::path::Path::new(raw).exists() {
return import_foreign(app, &path);
}
let ws_path = app.workspace.join(raw);
if ws_path.is_file() {
return import_foreign(app, &ws_path);
}
let manager = match crate::session_manager::SessionManager::default_location() {
Ok(m) => m,
Err(e) => return CommandResult::error(format!("could not open sessions directory: {e}")),
};
let session = manager
.load_session(raw)
.or_else(|_| manager.load_session_by_prefix(raw));
match session {
Ok(sess) => {
let path = manager
.sessions_dir()
.join(format!("{}.json", sess.metadata.id));
if path.exists() {
return CommandResult::action(crate::tui::app::AppAction::LoadSession(path));
}
CommandResult::message(format!(
"Resuming session {} ({})",
crate::session_manager::truncate_id(&sess.metadata.id),
sess.metadata.title
))
}
Err(e) => {
if let Ok(container) = crate::session_tree::SessionImportContainer::from_json(raw) {
return import_container(app, container);
}
CommandResult::error(format!(
"Cannot resume '{raw}': {e}\nUse `/resume` without args to pick, or pass a session id, or a path to an exported session JSON."
))
}
}
}
fn import_foreign(app: &mut App, path: &PathBuf) -> CommandResult {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
return CommandResult::error(format!(
"failed to read import file {}: {e}",
path.display()
));
}
};
if let Ok(container) = crate::session_tree::SessionImportContainer::from_json(&content) {
return import_container(app, container);
}
if let Ok(foreign) = serde_json::from_str::<crate::session_manager::SavedSession>(&content) {
let container = foreign.export_container("foreign");
return import_container(app, container);
}
CommandResult::error(format!(
"File {} is not a recognized session export",
path.display()
))
}
fn import_container(
app: &mut App,
container: crate::session_tree::SessionImportContainer,
) -> CommandResult {
let manager = match crate::session_manager::SessionManager::default_location() {
Ok(m) => m,
Err(e) => return CommandResult::error(format!("could not open sessions directory: {e}")),
};
let model = app.model.clone();
let workspace = app.workspace.clone();
let imported =
match crate::session_manager::SavedSession::import_foreign(container, workspace, model) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("foreign import failed: {e}")),
};
let new_id = imported.metadata.id.clone();
if let Err(e) = manager.save_session(&imported) {
return CommandResult::error(format!("imported session could not be saved: {e}"));
}
app.current_session_id = Some(new_id.clone());
app.current_session_metadata = Some(imported.metadata.clone());
app.api_messages = imported.messages.clone();
app.view_stack.push(SessionPickerView::new_selecting(
&app.workspace,
app.ui_locale,
&new_id,
));
CommandResult::message(format!(
"Imported foreign session as {} ({} entries, leaf {})",
crate::session_manager::truncate_id(&new_id),
imported
.journal
.as_ref()
.map(|j| j.entries.len())
.unwrap_or(0),
imported.leaf_id.as_deref().unwrap_or("(none)")
))
}
@@ -80,6 +80,99 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult {
}
}
/// Fork a specific session by id/prefix into a new sibling session and switch to it.
/// This implements `/fork <session_id>` for picker-based forking (#576).
pub fn fork_from_session(app: &mut App, session_id_or_prefix: &str) -> CommandResult {
if app.session_transition_blocked() {
return CommandResult::error(
"Cannot fork a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first.",
);
}
let manager = match crate::session_manager::SessionManager::default_location() {
Ok(m) => m,
Err(err) => {
return CommandResult::error(format!("could not open sessions directory: {err}"));
}
};
let source = manager
.load_session(session_id_or_prefix)
.or_else(|_| manager.load_session_by_prefix(session_id_or_prefix));
let mut source_session = match source {
Ok(s) => s,
Err(e) => {
return CommandResult::error(format!(
"could not load session '{}': {e}",
session_id_or_prefix
));
}
};
source_session.ensure_journal();
let mut journal = source_session.journal.clone().unwrap_or_else(|| {
crate::session_tree::SessionJournal::from_messages(
source_session.messages.clone(),
source_session.metadata.spawn_depth,
)
});
let forked_journal = journal.fork_from(None).unwrap_or_else(|_| {
crate::session_tree::SessionJournal::with_spawn_depth(
source_session.metadata.spawn_depth.saturating_add(1),
)
});
let messages = forked_journal.to_messages();
let mut forked = crate::session_manager::create_saved_session_with_id_and_mode(
uuid::Uuid::new_v4().to_string(),
&messages,
&source_session.metadata.model,
&app.workspace,
source_session.metadata.total_tokens,
source_session
.system_prompt
.as_ref()
.map(|s| crate::models::SystemPrompt::Text(s.clone())),
source_session.metadata.mode.as_deref(),
);
forked.journal = Some(forked_journal);
forked.leaf_id = forked.journal.as_ref().and_then(|j| j.leaf_id.clone());
forked.messages = messages;
forked.metadata.spawn_depth = forked.journal.as_ref().map(|j| j.spawn_depth).unwrap_or(0);
forked.metadata.parent_session_id = Some(source_session.metadata.id.clone());
forked.metadata.forked_from_message_count = Some(source_session.metadata.message_count);
forked.metadata.set_model_provider_route(
source_session.metadata.model_provider.as_str(),
source_session.metadata.model_provider_id.as_deref(),
);
forked.metadata.copy_cost_from(&source_session.metadata);
forked.context_references = source_session.context_references.clone();
forked.artifacts = source_session.artifacts.clone();
forked.work_state = source_session.work_state.clone();
forked.last_auto_route = source_session.last_auto_route.clone();
if let Err(err) = manager.save_session(&forked) {
return CommandResult::error(format!("Failed to save forked session: {err}"));
}
app.current_session_id = Some(forked.metadata.id.clone());
app.current_session_metadata = Some(forked.metadata.clone());
app.session_title = Some(forked.metadata.title.clone());
let parent_label = crate::session_manager::truncate_id(&source_session.metadata.id).to_string();
let fork_label = crate::session_manager::truncate_id(&forked.metadata.id).to_string();
CommandResult::with_message_and_action(
format!(
"Forked session {parent_label} -> {fork_label} (spawn_depth {})",
forked.metadata.spawn_depth
),
AppAction::SyncSession {
session_id: Some(forked.metadata.id.clone()),
messages: forked.messages.clone(),
system_prompt: forked
.system_prompt
.as_ref()
.map(|s| crate::models::SystemPrompt::Text(s.clone())),
model: forked.metadata.model.clone(),
workspace: app.workspace.clone(),
mode: app.mode,
},
)
}
/// Fork the active conversation into a new saved sibling session and switch to it.
pub fn fork(app: &mut App) -> CommandResult {
if app.session_transition_blocked() {
@@ -153,6 +246,14 @@ pub fn fork(app: &mut App) -> CommandResult {
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
forked.metadata.copy_cost_from(&parent.metadata);
forked.metadata.spawn_depth = parent.metadata.spawn_depth.saturating_add(1);
// Ensure journal for both sessions: parent already has one from factory, bump forked's journal depth
if let Some(j) = forked.journal.as_mut() {
j.spawn_depth = forked.metadata.spawn_depth;
}
if let Some(j) = parent.journal.as_mut() {
j.spawn_depth = parent.metadata.spawn_depth;
}
forked.metadata.mark_forked_from(&parent.metadata);
forked.context_references = app.session_context_references.clone();
forked.artifacts = app.session_artifacts.clone();
@@ -0,0 +1,67 @@
use super::CommandResult;
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::localization::MessageId;
use crate::session_tree::render_tree;
use crate::tui::app::App;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "tree",
aliases: &[],
usage: "/tree [interactive]",
description_id: MessageId::CmdTreeDescription,
};
pub(in crate::commands) struct TreeCmd;
impl RegisterCommand for TreeCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
tree(app, arg)
}
}
fn tree(app: &mut App, _arg: Option<&str>) -> CommandResult {
let manager = match crate::session_manager::SessionManager::default_location() {
Ok(m) => m,
Err(e) => return CommandResult::error(format!("could not open sessions directory: {e}")),
};
if let Some(session_id) = app.current_session_id.clone() {
if let Ok(mut session) = manager.load_session(&session_id) {
session.ensure_journal();
if let Some(journal) = session.journal.as_ref() {
let rendered = render_tree(journal);
let mut out = rendered;
out.push_str("\nUse `/branch <entry_id>` to branch (moves leaf only, never rewrites history).\n");
out.push_str("Use `/fork [session_id]` to fork this session at any node.\n");
return CommandResult::message(out);
}
}
if app.api_messages.is_empty() {
return CommandResult::message(
"(empty session — no entries yet)\nSend a message first, then `/tree` will show the entry journal.",
);
}
let mut out = String::from("Active branch (linear — journal will be created on save):\n");
for (i, msg) in app.api_messages.iter().enumerate() {
let snippet: String = msg
.content
.iter()
.filter_map(|b| match b {
crate::models::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ");
let short: String = snippet.chars().take(60).collect();
let marker = if i + 1 == app.api_messages.len() {
"*"
} else {
""
};
out.push_str(&format!(" {marker} [{i}] {}: {short}\n", msg.role));
}
out.push_str("\nUse `/branch <n>` with entry id after journal is saved.\n");
return CommandResult::message(out);
}
CommandResult::message(
"No active session. Use `/resume` to pick a session, then `/tree` to see its journal.",
)
}
+8
View File
@@ -15,6 +15,14 @@
#![deny(clippy::print_stderr)]
pub mod authority;
// `crates/core` now owns the engine (issue #5261). The TUI's `engine`
// re-exports the core boundary so headless and TUI share one `Op`-in /
// `EventMsg`-out API, one `ThreadId`/`SessionId` type, and one
// `Journal` shape. New code should import from `codewhale_core`.
pub use codewhale_core::engine as core_engine;
pub use codewhale_core::ids::{SessionId as CoreSessionId, ThreadId as CoreThreadId};
pub use codewhale_core::journal::{Journal as CoreJournal, JournalEntry as CoreJournalEntry};
pub use codewhale_core::session::{Session as CoreSession, Thread as CoreThread};
pub mod engine;
pub mod events;
// The first production consumer of the staged runtime contract is the
+1
View File
@@ -123,6 +123,7 @@ mod session_manager;
mod session_peek;
mod session_projection;
mod session_resume;
pub mod session_tree;
mod settings;
mod shell_dispatcher;
mod skill_state;
+3
View File
@@ -417,6 +417,9 @@ pub enum MessageId {
CmdForkDescription,
CmdNewDescription,
CmdSessionsDescription,
CmdTreeDescription,
CmdBranchDescription,
CmdResumeDescription,
CmdSettingsDescription,
CmdSidebarDescription,
CmdSkillDescription,
+41 -5
View File
@@ -1,11 +1,19 @@
//! One typed, capped, marker-stable ModelContext fragment.
//!
//! Caps and identities are unified with `codewhale_core::fragments` — the
//! single `crates/core` owner for the bounded fragment system (issue #5264).
//! This crate re-exports the core caps so every injection goes through a
//! typed fragment with a `matches_text` recognizer and the hard caps
//! (per-fragment size, 10K-token ceiling, injected-item count) are enforced
//! in one place.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
/// Default hard byte cap per volatile fragment. Keeps WorldState from
/// displacing the cache-stable constitution prefix under fanout noise.
pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024;
/// Re-export the core hard caps — single source of truth.
pub use codewhale_core::fragments::{
DEFAULT_FRAGMENT_MAX_BYTES, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_TOKENS, MAX_FRAGMENTS_PER_CONTEXT,
};
/// Stable identity for a WorldState concern. Markers are public contract —
/// do not rename without a migration note (prefix-cache + tests pin them).
@@ -17,6 +25,8 @@ pub enum FragmentId {
AgentTopology,
SkillsTools,
TokenBudget,
ProjectInstructions,
Constitution,
}
impl FragmentId {
@@ -30,6 +40,8 @@ impl FragmentId {
Self::AgentTopology => "agent_topology",
Self::SkillsTools => "skills_tools",
Self::TokenBudget => "token_budget",
Self::ProjectInstructions => "project_instructions",
Self::Constitution => "constitution",
}
}
@@ -43,6 +55,8 @@ impl FragmentId {
Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
Self::ProjectInstructions => "<!-- cw:ctx:project_instructions -->",
Self::Constitution => "<!-- cw:ctx:constitution -->",
}
}
@@ -56,6 +70,8 @@ impl FragmentId {
Self::AgentTopology => FragmentRole::AgentTopology,
Self::SkillsTools => FragmentRole::SkillsTools,
Self::TokenBudget => FragmentRole::TokenBudget,
Self::ProjectInstructions => FragmentRole::ProjectInstructions,
Self::Constitution => FragmentRole::Constitution,
}
}
@@ -69,6 +85,8 @@ impl FragmentId {
Self::AgentTopology,
Self::SkillsTools,
Self::TokenBudget,
Self::ProjectInstructions,
Self::Constitution,
]
}
}
@@ -88,6 +106,10 @@ pub enum FragmentRole {
SkillsTools,
/// Token budget and compaction status.
TokenBudget,
/// Project instructions (AGENTS.md + imported instruction files).
ProjectInstructions,
/// Codewhale-specific repo constitution.
Constitution,
}
impl FragmentRole {
@@ -101,6 +123,8 @@ impl FragmentRole {
Self::AgentTopology => "agent_topology",
Self::SkillsTools => "skills_tools",
Self::TokenBudget => "token_budget",
Self::ProjectInstructions => "project_instructions",
Self::Constitution => "constitution",
}
}
}
@@ -141,18 +165,30 @@ impl ModelContextFragment {
raw: impl Into<String>,
max_bytes: usize,
) -> Self {
let content = enforce_byte_cap(raw.into(), max_bytes);
// Clamp to the global 10K-token ceiling so callers cannot opt out.
let clamped_max = max_bytes.min(MAX_FRAGMENT_BYTES);
let mut content = enforce_byte_cap(raw.into(), clamped_max);
// Token-ceiling safety net (4 bytes ≈ 1 token).
if content.len().div_ceil(4) > MAX_FRAGMENT_TOKENS {
content = enforce_byte_cap(content, MAX_FRAGMENT_BYTES);
}
let content_hash = hash_content(&content);
Self {
id,
role,
marker: id.marker(),
max_bytes,
max_bytes: clamped_max,
content,
content_hash,
}
}
/// `matches_text` recognizer: true when `haystack` contains this fragment's marker.
#[must_use]
pub fn matches_text(&self, haystack: &str) -> bool {
haystack.contains(self.marker)
}
/// Compare against a previous fragment of the same id.
#[must_use]
pub fn render_diff(&self, previous: Option<&Self>) -> FragmentRender {
@@ -193,6 +193,60 @@ impl WorldState {
));
self
}
#[must_use]
pub fn with_project_instructions(mut self, body: impl Into<String>) -> Self {
self.upsert(ModelContextFragment::new(
FragmentId::ProjectInstructions,
FragmentRole::ProjectInstructions,
body,
));
self
}
#[must_use]
pub fn with_constitution_fragment(mut self, body: impl Into<String>) -> Self {
self.upsert(ModelContextFragment::new(
FragmentId::Constitution,
FragmentRole::Constitution,
body,
));
self
}
/// Enforce the hard caps for this WorldState. Returns an error if the
/// fragment count or any fragment's byte/token size exceeds the core
/// ceilings (`MAX_FRAGMENT_BYTES` / `MAX_FRAGMENT_TOKENS`).
pub fn validate_caps(&self) -> Result<(), String> {
use crate::model_context::fragment::{
MAX_FRAGMENT_BYTES, MAX_FRAGMENT_TOKENS, MAX_FRAGMENTS_PER_CONTEXT,
};
if self.fragments.len() > MAX_FRAGMENTS_PER_CONTEXT {
return Err(format!(
"too many fragments: {} > {}",
self.fragments.len(),
MAX_FRAGMENTS_PER_CONTEXT
));
}
for fragment in self.fragments.values() {
if fragment.content.len() > MAX_FRAGMENT_BYTES {
return Err(format!(
"fragment {:?} exceeds byte ceiling: {} > {}",
fragment.id,
fragment.content.len(),
MAX_FRAGMENT_BYTES
));
}
let tokens = fragment.content.len().div_ceil(4);
if tokens > MAX_FRAGMENT_TOKENS {
return Err(format!(
"fragment {:?} exceeds token ceiling: {} > {}",
fragment.id, tokens, MAX_FRAGMENT_TOKENS
));
}
}
Ok(())
}
}
/// Constitution (cache-stable) + WorldState (volatile) assembly point.
+13 -1
View File
@@ -1219,7 +1219,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
// Token-budget / continuity fragment: prior-session handoff relay.
let token_budget_body = load_handoff_block(workspace);
let world_state = world_state_from_session_facts(
let mut world_state = world_state_from_session_facts(
Some(workspace_body.as_str()),
permissions_body.as_deref(),
Some(route_body.as_str()),
@@ -1227,6 +1227,18 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
None, // Skills stay in the constitution prefix (skills-dir-static).
token_budget_body.as_deref(),
);
// Project-instruction import (#3978, #4079) as a typed fragment with
// hard caps — unified with `codewhale_core::fragments`. This covers
// `.cursorrules`, `.clinerules`, `.windsurf/rules/*`, `.gemini/*`,
// `.github/copilot-instructions.md` etc., beyond the canonical
// `AGENTS.md` already in the constitution prefix.
if let Some(fragment) = codewhale_core::fragments::load_project_instruction_fragment(workspace)
{
// `BoundedFragment` already enforces `MAX_FRAGMENT_BYTES` (10K-token
// ceiling) and per-fragment caps; WorldState's `with_*` also clamps.
world_state = world_state.with_project_instructions(fragment.content);
debug_assert!(world_state.validate_caps().is_ok());
}
let mut blocks = crate::model_context::WorldStateSnapshot {
constitution: full_prompt,
+165 -1
View File
@@ -10,6 +10,7 @@ use crate::artifacts::ArtifactRecord;
use crate::config::ApiProvider;
use crate::model_routing::AutoRouteReceipt;
use crate::models::{ContentBlock, Message, SystemPrompt};
use crate::session_tree::{SessionEntry, SessionImportContainer, SessionJournal};
use crate::tools::plan::PlanSnapshot;
use crate::tools::todo::TodoListSnapshot;
use crate::tui::file_mention::ContextReference;
@@ -165,6 +166,8 @@ pub struct SessionMetadata {
/// until the flag is actually set.
#[serde(default, skip_serializing_if = "is_not_archived")]
pub archived: bool,
#[serde(default)]
pub spawn_depth: u32,
}
fn is_not_archived(archived: &bool) -> bool {
@@ -490,6 +493,7 @@ pub(crate) struct SavedAutoRouteReceipt {
}
/// A saved session containing full conversation history
/// Starting with v0.9.5 (#5262) the canonical history is the append-only entry journal (`journal` / `leaf_id`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedSession {
/// Schema version for migration compatibility
@@ -497,8 +501,12 @@ pub struct SavedSession {
pub schema_version: u32,
/// Session metadata
pub metadata: SessionMetadata,
/// Conversation messages
/// Conversation messages — derived from the journal's active branch (kept for compat).
pub messages: Vec<Message>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub journal: Option<SessionJournal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<String>,
/// System prompt if any
pub system_prompt: Option<String>,
/// Compact linked context references for user-visible `@path` and
@@ -517,6 +525,119 @@ pub struct SavedSession {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) last_auto_route: Option<SavedAutoRouteReceipt>,
}
impl SavedSession {
pub fn ensure_journal(&mut self) {
if self.journal.is_some() {
if self.leaf_id.is_none() {
self.leaf_id = self.journal.as_ref().and_then(|j| j.leaf_id.clone());
}
let active = self
.journal
.as_ref()
.map(|j| j.to_messages())
.unwrap_or_default();
if !active.is_empty() {
self.messages = active;
self.metadata.message_count = self.messages.len();
}
return;
}
let journal =
SessionJournal::from_messages(self.messages.clone(), self.metadata.spawn_depth);
self.leaf_id = journal.leaf_id.clone();
self.journal = Some(journal);
}
pub fn journal_append_message(&mut self, message: Message) -> String {
self.ensure_journal();
let journal = self.journal.as_mut().expect("journal ensured");
let id = journal.append_message(message.clone());
self.leaf_id = journal.leaf_id.clone();
self.messages = journal.to_messages();
self.metadata.message_count = self.messages.len();
self.metadata.updated_at = Utc::now();
id
}
pub fn journal_branch_to(&mut self, entry_id: &str) -> Result<(), String> {
self.ensure_journal();
let journal = self.journal.as_mut().expect("journal ensured");
journal.branch_to(entry_id)?;
self.leaf_id = journal.leaf_id.clone();
self.messages = journal.to_messages();
self.metadata.updated_at = Utc::now();
Ok(())
}
pub fn active_entries(&self) -> Vec<SessionEntry> {
self.journal
.as_ref()
.map(|j| j.root_to_leaf().into_iter().cloned().collect())
.unwrap_or_default()
}
pub fn export_container(&self, source: &str) -> SessionImportContainer {
let journal = self.journal.clone().unwrap_or_else(|| {
SessionJournal::from_messages(self.messages.clone(), self.metadata.spawn_depth)
});
SessionImportContainer::new(
source.to_string(),
&journal,
serde_json::to_value(&self.metadata).ok(),
)
}
pub fn import_foreign(
container: SessionImportContainer,
workspace: PathBuf,
model: String,
) -> Result<Self, String> {
let journal = container.into_journal()?;
let leaf_id = journal.leaf_id.clone();
let messages = journal.to_messages();
let now = Utc::now();
let spawn_depth = journal.spawn_depth.saturating_add(1);
let title = messages
.iter()
.find(|m| m.role == "user")
.and_then(|m| {
m.content.iter().find_map(|b| match b {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
})
.map(|s| crate::session_manager::truncate_title(s, 50))
.unwrap_or_else(|| crate::session_manager::DEFAULT_SESSION_TITLE.to_string());
let metadata = SessionMetadata {
id: Uuid::new_v4().to_string(),
title,
created_at: now,
updated_at: now,
message_count: messages.len(),
total_tokens: 0,
model,
model_provider: default_model_provider(),
model_provider_id: None,
workspace,
mode: None,
cost: SessionCostSnapshot::default(),
parent_session_id: None,
forked_from_message_count: None,
cumulative_turn_secs: 0,
archived: false,
spawn_depth,
};
let mut journal = journal;
journal.spawn_depth = spawn_depth;
Ok(Self {
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
metadata,
messages,
journal: Some(journal),
leaf_id,
system_prompt: None,
context_references: Vec::new(),
artifacts: Vec::new(),
work_state: None,
last_auto_route: None,
})
}
}
/// Manager for session persistence operations
#[derive(Debug)]
@@ -981,6 +1102,8 @@ impl SessionManager {
);
}
session.ensure_journal();
Ok(session)
}
@@ -1532,6 +1655,8 @@ pub fn create_saved_session_with_id_and_mode(
})
.unwrap_or_else(|| DEFAULT_SESSION_TITLE.to_string());
let journal = SessionJournal::from_messages(messages.to_vec(), 0);
let leaf_id = journal.leaf_id.clone();
SavedSession {
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
metadata: SessionMetadata {
@@ -1551,8 +1676,11 @@ pub fn create_saved_session_with_id_and_mode(
forked_from_message_count: None,
cumulative_turn_secs: 0,
archived: false,
spawn_depth: 0,
},
messages: messages.to_vec(),
journal: Some(journal),
leaf_id,
system_prompt: system_prompt_to_string(system_prompt),
context_references: Vec::new(),
artifacts: Vec::new(),
@@ -1569,6 +1697,42 @@ pub fn update_session(
system_prompt: Option<&SystemPrompt>,
) -> SavedSession {
session.schema_version = CURRENT_SESSION_SCHEMA_VERSION;
session.ensure_journal();
let old_len = session.messages.len();
let new_len = messages.len();
if new_len >= old_len && messages[..old_len] == session.messages[..] {
if let Some(journal) = session.journal.as_mut() {
for msg in &messages[old_len..] {
journal.append_message(msg.clone());
}
session.leaf_id = journal.leaf_id.clone();
}
} else if new_len != old_len || messages != session.messages.as_slice() {
if let Some(journal) = session.journal.as_mut() {
let common = messages
.iter()
.zip(session.messages.iter())
.take_while(|(a, b)| a == b)
.count();
if common > 0 && common <= journal.entries.len() {
let path = journal.root_to_leaf();
if let Some(entry) = path.get(common - 1) {
let _ = journal.branch_to(&entry.id);
} else {
journal.leaf_id = None;
}
} else if common == 0 {
journal.leaf_id = journal.entries.first().and_then(|e| e.parent_id.clone());
if journal.leaf_id.is_none() && !journal.entries.is_empty() {
journal.leaf_id = None;
}
}
for msg in messages.iter().skip(common) {
journal.append_message(msg.clone());
}
session.leaf_id = journal.leaf_id.clone();
}
}
session.messages.clear();
session.messages.extend_from_slice(messages);
session.metadata.updated_at = Utc::now();
+566
View File
@@ -0,0 +1,566 @@
use crate::models::{ContentBlock, Message};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
pub const CURRENT_JOURNAL_SCHEMA_VERSION: u32 = 1;
pub type EntryId = String;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SpawnDepth(pub u32);
impl SpawnDepth {
pub fn next(self) -> Self {
Self(self.0.saturating_add(1))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SessionEntryKind {
Message {
message: Message,
},
User {
text: String,
},
Assistant {
text: String,
},
Compaction {
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens_before: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens_after: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
},
BranchSummary {
branch_id: String,
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_branch_id: Option<String>,
},
System {
content: String,
},
}
impl SessionEntryKind {
pub fn is_contextual(&self) -> bool {
matches!(
self,
Self::Message { .. } | Self::User { .. } | Self::Assistant { .. }
)
}
pub fn as_message(&self) -> Option<Message> {
match self {
Self::Message { message } => Some(message.clone()),
Self::User { text } => Some(Message {
role: "user".into(),
content: vec![ContentBlock::Text {
text: text.clone(),
cache_control: None,
}],
}),
Self::Assistant { text } => Some(Message {
role: "assistant".into(),
content: vec![ContentBlock::Text {
text: text.clone(),
cache_control: None,
}],
}),
Self::Compaction { summary, .. } => Some(Message {
role: "system".into(),
content: vec![ContentBlock::Text {
text: format!("[compaction summary] {summary}"),
cache_control: None,
}],
}),
Self::BranchSummary { summary, .. } => Some(Message {
role: "system".into(),
content: vec![ContentBlock::Text {
text: format!("[branch summary] {summary}"),
cache_control: None,
}],
}),
Self::System { content } => Some(Message {
role: "system".into(),
content: vec![ContentBlock::Text {
text: content.clone(),
cache_control: None,
}],
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionEntry {
pub id: EntryId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<EntryId>,
#[serde(flatten)]
pub kind: SessionEntryKind,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub spawn_depth: u32,
}
impl SessionEntry {
pub fn new(kind: SessionEntryKind, parent_id: Option<EntryId>, spawn_depth: u32) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
parent_id,
kind,
created_at: Utc::now(),
spawn_depth,
}
}
pub fn short_id(&self) -> &str {
if self.id.len() >= 8 {
&self.id[..8]
} else {
&self.id
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct SessionJournal {
#[serde(default)]
pub entries: Vec<SessionEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<EntryId>,
#[serde(default = "default_journal_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub spawn_depth: u32,
}
fn default_journal_schema_version() -> u32 {
CURRENT_JOURNAL_SCHEMA_VERSION
}
impl SessionJournal {
pub fn new() -> Self {
Self {
entries: Vec::new(),
leaf_id: None,
schema_version: CURRENT_JOURNAL_SCHEMA_VERSION,
spawn_depth: 0,
}
}
pub fn with_spawn_depth(depth: u32) -> Self {
Self {
spawn_depth: depth,
..Self::new()
}
}
pub fn append(&mut self, kind: SessionEntryKind) -> EntryId {
let entry = SessionEntry::new(kind, self.leaf_id.clone(), self.spawn_depth);
let id = entry.id.clone();
self.entries.push(entry);
self.leaf_id = Some(id.clone());
id
}
pub fn append_message(&mut self, message: Message) -> EntryId {
self.append(SessionEntryKind::Message { message })
}
pub fn append_compaction(
&mut self,
summary: String,
tokens_before: Option<u64>,
tokens_after: Option<u64>,
model: Option<String>,
) -> EntryId {
self.append(SessionEntryKind::Compaction {
summary,
tokens_before,
tokens_after,
model,
})
}
pub fn append_branch_summary(
&mut self,
branch_id: String,
summary: String,
parent_branch_id: Option<String>,
) -> EntryId {
self.append(SessionEntryKind::BranchSummary {
branch_id,
summary,
parent_branch_id,
})
}
pub fn branch_to(&mut self, entry_id: &str) -> Result<(), String> {
if self.entries.iter().any(|e| e.id == entry_id) {
self.leaf_id = Some(entry_id.to_string());
Ok(())
} else {
Err(format!("entry {entry_id} not found"))
}
}
pub fn fork_from(&self, from_entry_id: Option<&str>) -> Result<Self, String> {
let leaf = if let Some(id) = from_entry_id {
if !self.entries.iter().any(|e| e.id == id) {
return Err(format!("fork source {id} not found"));
}
Some(id.to_string())
} else {
self.leaf_id.clone()
};
Ok(Self {
entries: self.entries.clone(),
leaf_id: leaf,
schema_version: self.schema_version,
spawn_depth: self.spawn_depth.saturating_add(1),
})
}
pub fn index(&self) -> HashMap<&str, &SessionEntry> {
self.entries.iter().map(|e| (e.id.as_str(), e)).collect()
}
pub fn children_of(&self, parent_id: Option<&str>) -> Vec<&SessionEntry> {
self.entries
.iter()
.filter(|e| e.parent_id.as_deref() == parent_id)
.collect()
}
pub fn contains(&self, entry_id: &str) -> bool {
self.entries.iter().any(|e| e.id == entry_id)
}
pub fn leaf(&self) -> Option<&SessionEntry> {
self.leaf_id
.as_deref()
.and_then(|id| self.entries.iter().find(|e| e.id == id))
}
pub fn root_to_leaf(&self) -> Vec<&SessionEntry> {
let index: HashMap<&str, &SessionEntry> =
self.entries.iter().map(|e| (e.id.as_str(), e)).collect();
let mut path = Vec::new();
let mut cur = self.leaf_id.as_deref();
let mut seen = HashSet::new();
while let Some(id) = cur {
if !seen.insert(id) {
break;
}
if let Some(entry) = index.get(id) {
path.push(*entry);
cur = entry.parent_id.as_deref();
} else {
break;
}
}
path.reverse();
path
}
pub fn active_messages(&self, include_system: bool) -> Vec<Message> {
self.root_to_leaf()
.into_iter()
.filter_map(|e| {
if !include_system && !e.kind.is_contextual() {
return None;
}
e.kind.as_message()
})
.collect()
}
pub fn leaves(&self) -> Vec<&SessionEntry> {
let parents: HashSet<&str> = self
.entries
.iter()
.filter_map(|e| e.parent_id.as_deref())
.collect();
self.entries
.iter()
.filter(|e| !parents.contains(e.id.as_str()))
.collect()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn validate(&self) -> Result<(), String> {
let ids: HashSet<&str> = self.entries.iter().map(|e| e.id.as_str()).collect();
for entry in &self.entries {
if let Some(parent) = entry.parent_id.as_deref() {
if !ids.contains(parent) {
return Err(format!("entry {} missing parent {}", entry.id, parent));
}
}
}
if let Some(leaf) = self.leaf_id.as_deref() {
if !ids.contains(leaf) {
return Err(format!("leaf {leaf} not found"));
}
}
Ok(())
}
pub fn from_messages(messages: Vec<Message>, spawn_depth: u32) -> Self {
let mut j = Self::with_spawn_depth(spawn_depth);
for msg in messages {
j.append(SessionEntryKind::Message { message: msg });
}
j
}
pub fn to_messages(&self) -> Vec<Message> {
self.active_messages(true)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionImportContainer {
pub format_version: u32,
pub source: String,
pub metadata: Option<serde_json::Value>,
pub entries: Vec<SessionEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<EntryId>,
pub exported_at: DateTime<Utc>,
#[serde(default)]
pub spawn_depth: u32,
}
impl SessionImportContainer {
pub fn new(
source: String,
journal: &SessionJournal,
metadata: Option<serde_json::Value>,
) -> Self {
Self {
format_version: CURRENT_JOURNAL_SCHEMA_VERSION,
source,
metadata,
entries: journal.entries.clone(),
leaf_id: journal.leaf_id.clone(),
exported_at: Utc::now(),
spawn_depth: journal.spawn_depth,
}
}
pub fn into_journal(self) -> Result<SessionJournal, String> {
let j = SessionJournal {
entries: self.entries,
leaf_id: self.leaf_id,
schema_version: self.format_version,
spawn_depth: self.spawn_depth,
};
j.validate()?;
Ok(j)
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
pub fn render_tree(journal: &SessionJournal) -> String {
if journal.entries.is_empty() {
return "(empty session — no entries yet)".to_string();
}
let index = journal.index();
let mut out = String::new();
let mut children: HashMap<Option<&str>, Vec<&SessionEntry>> = HashMap::new();
for entry in &journal.entries {
children
.entry(entry.parent_id.as_deref())
.or_default()
.push(entry);
}
let active_ids: HashSet<&str> = journal
.root_to_leaf()
.iter()
.map(|e| e.id.as_str())
.collect();
let leaf = journal.leaf_id.as_deref();
fn render_node(
out: &mut String,
children: &HashMap<Option<&str>, Vec<&SessionEntry>>,
active_ids: &HashSet<&str>,
leaf: Option<&str>,
parent: Option<&str>,
depth: usize,
) {
let Some(nodes) = children.get(&parent) else {
return;
};
for (idx, entry) in nodes.iter().enumerate() {
let is_last = idx + 1 == nodes.len();
let prefix = if depth == 0 {
"".to_string()
} else {
let mut p = String::new();
for _ in 0..depth - 1 {
p.push_str("");
}
if is_last {
p.push_str("└─ ");
} else {
p.push_str("├─ ");
}
p
};
let marker = if Some(entry.id.as_str()) == leaf {
"*"
} else if active_ids.contains(entry.id.as_str()) {
""
} else {
""
};
let kind_label = match &entry.kind {
SessionEntryKind::Message { message } => {
let role = &message.role;
let snippet: String = message
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ");
let short: String = snippet.chars().take(60).collect();
format!("{role}: {short}")
}
SessionEntryKind::User { text } => {
let short: String = text.chars().take(60).collect();
format!("user: {short}")
}
SessionEntryKind::Assistant { text } => {
let short: String = text.chars().take(60).collect();
format!("assistant: {short}")
}
SessionEntryKind::Compaction { summary, .. } => {
let short: String = summary.chars().take(60).collect();
format!("compaction: {short}")
}
SessionEntryKind::BranchSummary { branch_id, summary } => {
let short: String = summary.chars().take(60).collect();
format!("branch:{} {short}", &branch_id[..branch_id.len().min(8)])
}
SessionEntryKind::System { content } => {
let short: String = content.chars().take(60).collect();
format!("system: {short}")
}
};
out.push_str(&format!(
"{prefix}{marker} {} [{}] {kind_label}\n",
entry.short_id(),
entry.id
));
render_node(out, children, active_ids, leaf, Some(&entry.id), depth + 1);
}
}
render_node(&mut out, &children, &active_ids, leaf, None, 0);
let _ = index;
if let Some(leaf_id) = leaf {
out.push_str(&format!(
"\nleaf: {leaf_id} (active, {} entries)\n",
journal.entries.len()
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ContentBlock, Message};
fn msg(role: &str, text: &str) -> Message {
Message {
role: role.to_string(),
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
}
}
#[test]
fn append_creates_child_of_leaf() {
let mut j = SessionJournal::new();
let a = j.append(SessionEntryKind::User {
text: "hello".into(),
});
let b = j.append(SessionEntryKind::Assistant {
text: "world".into(),
});
assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
let be = j.entries.iter().find(|e| e.id == b).unwrap();
assert_eq!(be.parent_id.as_deref(), Some(a.as_str()));
}
#[test]
fn branch_moves_leaf_only() {
let mut j = SessionJournal::new();
let a = j.append(SessionEntryKind::User { text: "a".into() });
let b = j.append(SessionEntryKind::User { text: "b".into() });
let _c = j.append(SessionEntryKind::User { text: "c".into() });
j.branch_to(&a).unwrap();
let d = j.append(SessionEntryKind::User { text: "d".into() });
assert_eq!(j.entries.len(), 4);
assert_eq!(j.leaf_id.as_deref(), Some(d.as_str()));
let path: Vec<String> = j.root_to_leaf().iter().map(|e| e.id.clone()).collect();
assert_eq!(path, vec![a.clone(), d.clone()]);
assert!(j.entries.iter().any(|e| e.id == b));
}
#[test]
fn from_messages_migrates() {
let msgs = vec![msg("user", "hi"), msg("assistant", "hello")];
let j = SessionJournal::from_messages(msgs, 0);
assert_eq!(j.entries.len(), 2);
assert!(j.validate().is_ok());
assert_eq!(j.root_to_leaf().len(), 2);
}
#[test]
fn compaction_fits() {
let mut j = SessionJournal::new();
let id = j.append_compaction("summary".into(), Some(1000), Some(100), None);
assert!(j.contains(&id));
assert!(matches!(
j.leaf().unwrap().kind,
SessionEntryKind::Compaction { .. }
));
}
#[test]
fn branch_summary_fits() {
let mut j = SessionJournal::new();
let a = j.append(SessionEntryKind::User {
text: "root".into(),
});
let b = j.append_branch_summary(a.clone(), "branch summary".into(), None);
assert!(j.contains(&b));
}
#[test]
fn spawn_depth_fork() {
let mut j = SessionJournal::with_spawn_depth(1);
let forked = j.fork_from(None).unwrap();
assert_eq!(forked.spawn_depth, 2);
let a = j.append(SessionEntryKind::User {
text: "root".into(),
});
let fork2 = j.fork_from(Some(&a)).unwrap();
assert_eq!(fork2.spawn_depth, 2);
}
#[test]
fn foreign_roundtrip() {
let mut j = SessionJournal::new();
j.append(SessionEntryKind::User {
text: "hello".into(),
});
let c = SessionImportContainer::new("codewhale".into(), &j, None);
let json = c.to_json().unwrap();
let back = SessionImportContainer::from_json(&json).unwrap();
let j2 = back.into_journal().unwrap();
assert_eq!(j.entries.len(), j2.entries.len());
}
#[test]
fn render_marks_active() {
let mut j = SessionJournal::new();
j.append(SessionEntryKind::User {
text: "root".into(),
});
let tree = render_tree(&j);
assert!(tree.contains('*'));
}
#[test]
fn active_messages_root_to_leaf() {
let mut j = SessionJournal::new();
j.append(SessionEntryKind::User { text: "a".into() });
j.append(SessionEntryKind::User { text: "b".into() });
let msgs = j.active_messages(false);
assert_eq!(msgs.len(), 2);
j.branch_to(&j.entries[0].id.clone()).unwrap();
j.append(SessionEntryKind::User { text: "c".into() });
let msgs2 = j.active_messages(false);
assert_eq!(msgs2.len(), 2);
}
}
+185
View File
@@ -425,6 +425,190 @@ def update_command(receipt_path: Path | None, budget_path: Path) -> str:
return shlex.join(parts)
FRAGMENT_MODULE = REPO_ROOT / "crates" / "core" / "src" / "fragments.rs"
FRAGMENT_MAX_TOKENS_CEILING = 10_000
FRAGMENT_MAX_BYTES_CEILING = FRAGMENT_MAX_TOKENS_CEILING * 4
FRAGMENT_DEFAULT_MAX_BYTES_CEILING = 4 * 1024
FRAGMENT_MAX_COUNT_CEILING = 16
def check_fragment_caps() -> None:
"""Gate the bounded fragment hard caps (issue #5264).
Static check no cargo needed. Fails closed if the fragment module is
missing, if any cap has been raised without review, or if the
project-instruction import is absent.
"""
try:
text = FRAGMENT_MODULE.read_text(encoding="utf-8")
except FileNotFoundError as error:
raise RuntimeContractError(
f"missing bounded fragment module: {FRAGMENT_MODULE} ({error})"
) from error
def const_value(pattern: str) -> int:
match = re.search(pattern, text)
if not match:
raise RuntimeContractError(f"fragment cap missing: {pattern}")
try:
return int(match.group(1).replace("_", ""))
except ValueError as error:
raise RuntimeContractError(f"fragment cap not an int: {pattern}") from error
max_tokens = const_value(r"pub const MAX_FRAGMENT_TOKENS:\s*usize\s*=\s*([0-9_]+)")
if max_tokens != FRAGMENT_MAX_TOKENS_CEILING:
raise RuntimeContractError(
f"MAX_FRAGMENT_TOKENS must be {FRAGMENT_MAX_TOKENS_CEILING}, got {max_tokens}"
)
# MAX_FRAGMENT_BYTES must be defined as MAX_FRAGMENT_TOKENS * 4 (canonical)
# or as a literal 40000. Either way the derived ceiling is 40_000.
has_multiplication = re.search(
r"pub const MAX_FRAGMENT_BYTES:\s*usize\s*=\s*MAX_FRAGMENT_TOKENS\s*\*\s*4", text
)
bytes_literal = re.search(
r"pub const MAX_FRAGMENT_BYTES:\s*usize\s*=\s*([0-9_]+)", text
)
if bytes_literal:
literal = int(bytes_literal.group(1).replace("_", ""))
if literal != FRAGMENT_MAX_BYTES_CEILING:
raise RuntimeContractError(
f"MAX_FRAGMENT_BYTES must be {FRAGMENT_MAX_BYTES_CEILING}, got {literal}"
)
elif not has_multiplication:
raise RuntimeContractError(
"MAX_FRAGMENT_BYTES must be defined as MAX_FRAGMENT_TOKENS * 4 or as 40000"
)
# DEFAULT is defined as 4 * 1024 (canonical) or 4096 literal
has_default_multiplication = re.search(
r"pub const DEFAULT_FRAGMENT_MAX_BYTES:\s*usize\s*=\s*4\s*\*\s*1024", text
)
default_literal = re.search(
r"pub const DEFAULT_FRAGMENT_MAX_BYTES:\s*usize\s*=\s*([0-9_]+)", text
)
if has_default_multiplication:
# canonical 4*1024 == 4096, which equals ceiling
pass
elif default_literal:
default_bytes = int(default_literal.group(1).replace("_", ""))
if default_bytes != FRAGMENT_DEFAULT_MAX_BYTES_CEILING:
raise RuntimeContractError(
f"DEFAULT_FRAGMENT_MAX_BYTES must be {FRAGMENT_DEFAULT_MAX_BYTES_CEILING}, got {default_bytes}"
)
if default_bytes > FRAGMENT_MAX_BYTES_CEILING:
raise RuntimeContractError(
f"DEFAULT_FRAGMENT_MAX_BYTES ({default_bytes}) must not exceed MAX_FRAGMENT_BYTES ({FRAGMENT_MAX_BYTES_CEILING})"
)
else:
raise RuntimeContractError("DEFAULT_FRAGMENT_MAX_BYTES definition not found")
max_count = const_value(
r"pub const MAX_FRAGMENTS_PER_CONTEXT:\s*usize\s*=\s*([0-9_]+)"
)
if max_count != FRAGMENT_MAX_COUNT_CEILING:
raise RuntimeContractError(
f"MAX_FRAGMENTS_PER_CONTEXT must be {FRAGMENT_MAX_COUNT_CEILING}, got {max_count}"
)
if max_count > FRAGMENT_MAX_COUNT_CEILING:
raise RuntimeContractError(
f"MAX_FRAGMENTS_PER_CONTEXT ({max_count}) must not exceed {FRAGMENT_MAX_COUNT_CEILING}"
)
# Ensure every injection type is in FragmentId::all() and the
# project-instruction import is present as a typed fragment.
required_fragments = [
"Workspace",
"Permissions",
"Route",
"AgentTopology",
"SkillsTools",
"TokenBudget",
"ProjectInstructions",
"Constitution",
]
for name in required_fragments:
if f"Self::{name}" not in text and f"{name} =>" not in text and f'"{name.lower()}"' not in text.lower():
# Fallback: search for enum variant declaration
if not re.search(rf"\b{name}\b", text):
raise RuntimeContractError(
f"FragmentId missing required variant {name}"
)
# Marker stability — these strings are pinned by tests / prefix cache
required_markers = [
"<!-- cw:ctx:workspace -->",
"<!-- cw:ctx:project_instructions -->",
"<!-- cw:ctx:constitution -->",
]
for marker in required_markers:
if marker not in text:
raise RuntimeContractError(
f"bounded fragment module missing required marker {marker!r}"
)
# Project-instruction import must be a typed fragment, not ad-hoc
if "load_project_instruction_fragment" not in text:
raise RuntimeContractError(
"bounded fragment module must expose load_project_instruction_fragment (project-instruction import as typed fragment)"
)
if "PROJECT_INSTRUCTION_CANDIDATES" not in text:
raise RuntimeContractError(
"bounded fragment module must define PROJECT_INSTRUCTION_CANDIDATES"
)
# Required candidate files from #3978
required_candidates = [
".cursorrules",
".clinerules",
".windsurf/rules",
".gemini",
".github/copilot-instructions.md",
]
for candidate in required_candidates:
if candidate not in text:
raise RuntimeContractError(
f"PROJECT_INSTRUCTION_CANDIDATES missing required entry {candidate!r}"
)
# matches_text recognizer must exist on the fragment trait
if "fn matches_text" not in text:
raise RuntimeContractError(
"bounded fragment module must define a matches_text recognizer on the fragment trait"
)
if "trait ContextFragment" not in text:
raise RuntimeContractError(
"bounded fragment module must define trait ContextFragment with matches_text"
)
# No unbounded fragment — enforce that creation clamps to MAX_FRAGMENT_BYTES
if "MAX_FRAGMENT_BYTES" not in text or "enforce_byte_cap" not in text:
raise RuntimeContractError(
"bounded fragment module must enforce byte caps via enforce_byte_cap and MAX_FRAGMENT_BYTES"
)
# TUI must be unified with the core boundary (shared crates/core module)
tui_fragment = REPO_ROOT / "crates" / "tui" / "src" / "model_context" / "fragment.rs"
try:
tui_text = tui_fragment.read_text(encoding="utf-8")
except FileNotFoundError as error:
raise RuntimeContractError(
f"missing TUI fragment module: {tui_fragment} ({error})"
) from error
if "codewhale_core::fragments" not in tui_text:
raise RuntimeContractError(
"TUI model_context/fragment.rs must re-export caps from codewhale_core::fragments (shared crates/core boundary)"
)
if "ProjectInstructions" not in tui_text:
raise RuntimeContractError(
"TUI fragment module must include ProjectInstructions variant (unified with core)"
)
if "MAX_FRAGMENT_BYTES" not in tui_text:
raise RuntimeContractError(
"TUI fragment module must enforce MAX_FRAGMENT_BYTES (10K-token ceiling)"
)
if "matches_text" not in tui_text:
raise RuntimeContractError(
"TUI fragment module must expose a matches_text recognizer"
)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
@@ -446,6 +630,7 @@ def main(argv: Sequence[str] | None = None) -> int:
args = parser.parse_args(argv)
try:
check_fragment_caps()
if args.receipt is not None and args.receipt.resolve() == args.budget.resolve():
raise RuntimeContractError(
"receipt and budget must resolve to distinct filesystem paths"