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:
Generated
+4
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod fragments;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -417,6 +417,9 @@ pub enum MessageId {
|
||||
CmdForkDescription,
|
||||
CmdNewDescription,
|
||||
CmdSessionsDescription,
|
||||
CmdTreeDescription,
|
||||
CmdBranchDescription,
|
||||
CmdResumeDescription,
|
||||
CmdSettingsDescription,
|
||||
CmdSidebarDescription,
|
||||
CmdSkillDescription,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user