Synced from monorepo
Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -16,7 +16,7 @@ mod tests;
|
|||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::commands::ChatStateCommand;
|
||||
use crate::commands::{ChatStateCommand, StrictAppendAck};
|
||||
use crate::events::ChatStateEvent;
|
||||
use crate::handle::ChatStateHandle;
|
||||
use crate::persistence::ChatPersistence;
|
||||
|
|
@ -107,14 +107,14 @@ impl ChatStateActor {
|
|||
debug!("ChatStateActor shutting down: all handles dropped");
|
||||
break;
|
||||
};
|
||||
self.handle_command(cmd);
|
||||
self.handle_command(cmd).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch a command to the appropriate mutation or query handler.
|
||||
fn handle_command(&mut self, cmd: ChatStateCommand) {
|
||||
async fn handle_command(&mut self, cmd: ChatStateCommand) {
|
||||
match cmd {
|
||||
// ═══ Mutations ═══
|
||||
ChatStateCommand::PushUserMessage { item } => {
|
||||
|
|
@ -124,6 +124,42 @@ impl ChatStateActor {
|
|||
self.push_user_message(item);
|
||||
let _ = reply.send(());
|
||||
}
|
||||
ChatStateCommand::AppendWorkingDirectorySwitchAndAck {
|
||||
content,
|
||||
cwd_generation,
|
||||
reply,
|
||||
} => {
|
||||
let generation = cwd_generation.get();
|
||||
let candidate = ConversationItem::working_directory_switch(content, generation);
|
||||
let persist_rx = self
|
||||
.persistence
|
||||
.persist_working_directory_switch_and_ack(&candidate);
|
||||
let result = persist_rx.await.unwrap_or_else(|_| {
|
||||
Err(crate::commands::StrictAppendError::Indeterminate(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"working-directory switch acknowledgement dropped; retry by generation",
|
||||
),
|
||||
))
|
||||
});
|
||||
let authoritative = match &result {
|
||||
Ok(StrictAppendAck::Appended)
|
||||
| Err(crate::commands::StrictAppendError::Committed {
|
||||
acknowledgement: StrictAppendAck::Appended,
|
||||
..
|
||||
}) => Some(&candidate),
|
||||
Ok(StrictAppendAck::AlreadyPresent(authoritative))
|
||||
| Err(crate::commands::StrictAppendError::Committed {
|
||||
acknowledgement: StrictAppendAck::AlreadyPresent(authoritative),
|
||||
..
|
||||
}) => Some(authoritative),
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(authoritative) = authoritative {
|
||||
self.converge_working_directory_switch(generation, authoritative.clone());
|
||||
}
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
ChatStateCommand::PushUserMessageWithRepairReason { item, reason } => {
|
||||
self.push_user_message_with_repair_reason(item, reason);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,37 @@ impl ChatStateActor {
|
|||
report
|
||||
}
|
||||
|
||||
/// Make memory match the disk-authoritative switch for one generation.
|
||||
pub(super) fn converge_working_directory_switch(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
authoritative: ConversationItem,
|
||||
) {
|
||||
let existing = self
|
||||
.state
|
||||
.conversation
|
||||
.iter_mut()
|
||||
.find(|item| item.working_directory_switch_generation() == Some(generation));
|
||||
if let Some(existing) = existing {
|
||||
let old_tokens = super::state::estimate_item_tokens(existing);
|
||||
let new_tokens = super::state::estimate_item_tokens(&authoritative);
|
||||
self.state.estimated_tokens_since_model = if new_tokens >= old_tokens {
|
||||
self.state
|
||||
.estimated_tokens_since_model
|
||||
.saturating_add(new_tokens - old_tokens)
|
||||
} else {
|
||||
self.state
|
||||
.estimated_tokens_since_model
|
||||
.saturating_sub(old_tokens - new_tokens)
|
||||
};
|
||||
*existing = authoritative;
|
||||
} else {
|
||||
self.state.estimated_tokens_since_model +=
|
||||
super::state::estimate_item_tokens(&authoritative);
|
||||
self.state.conversation.push(authoritative);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push any conversation item (user, assistant, or tool result) and persist it.
|
||||
pub(super) fn push_message(&mut self, item: ConversationItem) {
|
||||
let count_in_delta = !matches!(item, ConversationItem::Assistant(_));
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ impl ChatStateActor {
|
|||
x_grok_deployment_id: None,
|
||||
x_grok_user_id: None,
|
||||
trace,
|
||||
prompt_cache_key: None,
|
||||
reasoning_effort: self.state.sampling_config.reasoning_effort,
|
||||
json_schema: None,
|
||||
}
|
||||
|
|
@ -598,13 +599,13 @@ mod tests {
|
|||
|
||||
fn has_placeholder(item: &ConversationItem) -> bool {
|
||||
matches!(
|
||||
item,
|
||||
ConversationItem::User(u) if u.content.iter().any(|p| matches!(
|
||||
p,
|
||||
ContentPart::Text { text }
|
||||
if text.as_ref() == IMAGE_COMPACT_PLACEHOLDER
|
||||
))
|
||||
)
|
||||
item,
|
||||
ConversationItem::User(u) if u.content.iter().any(|p| matches!(
|
||||
p,
|
||||
ContentPart::Text { text }
|
||||
if text.as_ref() == IMAGE_COMPACT_PLACEHOLDER
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
// Images are sized ~100 KB so the ~235 B placeholder that replaces an
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::time::Duration;
|
|||
use tokio::sync::mpsc;
|
||||
use xai_grok_sampling_types::{ConversationItem, SamplingConfig};
|
||||
|
||||
use crate::StrictAppendAck;
|
||||
use crate::actor::ChatStateActor;
|
||||
use crate::events::ChatStateEvent;
|
||||
use crate::persistence::{MockChatPersistence, MockPersistenceReceiver, PersistenceRecord};
|
||||
|
|
@ -54,11 +55,23 @@ impl TestHarness {
|
|||
|
||||
fn with_config(items: Vec<ConversationItem>, config: SamplingConfig) -> Self {
|
||||
let (mock, persistence_rx) = MockChatPersistence::new();
|
||||
Self::with_persistence(items, config, mock, persistence_rx)
|
||||
}
|
||||
|
||||
fn with_manual_persistence_ack(items: Vec<ConversationItem>) -> Self {
|
||||
let (mock, persistence_rx) = MockChatPersistence::new_with_manual_persistence_ack();
|
||||
Self::with_persistence(items, test_config(), mock, persistence_rx)
|
||||
}
|
||||
|
||||
fn with_persistence(
|
||||
items: Vec<ConversationItem>,
|
||||
config: SamplingConfig,
|
||||
mock: MockChatPersistence,
|
||||
persistence_rx: MockPersistenceReceiver,
|
||||
) -> Self {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let token = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let handle = ChatStateActor::spawn(items, config, Box::new(mock), event_tx, token.clone());
|
||||
|
||||
Self {
|
||||
handle,
|
||||
event_rx,
|
||||
|
|
@ -155,6 +168,249 @@ async fn push_user_message_and_ack_waits_for_actor_acceptance() {
|
|||
assert!(matches!(&records[0], PersistenceRecord::Message(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_switch_append_preserves_prefix_and_deduplicates_generation() {
|
||||
let prefix = vec![
|
||||
ConversationItem::system("sys"),
|
||||
ConversationItem::assistant("assistant"),
|
||||
ConversationItem::tool_result("dangling", "must remain"),
|
||||
];
|
||||
let prefix_json: Vec<Vec<u8>> = prefix
|
||||
.iter()
|
||||
.map(|item| serde_json::to_vec(item).unwrap())
|
||||
.collect();
|
||||
let mut h = TestHarness::with_conversation(prefix);
|
||||
let reminder = ConversationItem::working_directory_switch("moved", 3);
|
||||
|
||||
assert!(matches!(
|
||||
h.handle
|
||||
.append_working_directory_switch_and_ack(
|
||||
"moved".into(),
|
||||
std::num::NonZeroU64::new(3).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
StrictAppendAck::Appended
|
||||
));
|
||||
let conversation = h.handle.get_conversation().await;
|
||||
assert_eq!(conversation.len(), 4);
|
||||
for (actual, expected) in conversation.iter().zip(&prefix_json) {
|
||||
assert_eq!(serde_json::to_vec(actual).unwrap(), *expected);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_vec(&conversation[3]).unwrap(),
|
||||
serde_json::to_vec(&reminder).unwrap()
|
||||
);
|
||||
assert!(matches!(
|
||||
h.drain_persistence().as_slice(),
|
||||
[PersistenceRecord::AcknowledgedMessage(_)]
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
h.handle
|
||||
.append_working_directory_switch_and_ack(
|
||||
"different text".into(),
|
||||
std::num::NonZeroU64::new(3).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
StrictAppendAck::AlreadyPresent(_)
|
||||
));
|
||||
assert_eq!(h.handle.get_conversation().await.len(), 4);
|
||||
assert!(matches!(
|
||||
h.drain_persistence().as_slice(),
|
||||
[PersistenceRecord::AcknowledgedMessage(_)]
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
h.handle
|
||||
.append_working_directory_switch_and_ack(
|
||||
"next move".into(),
|
||||
std::num::NonZeroU64::new(4).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
StrictAppendAck::Appended
|
||||
));
|
||||
assert_eq!(h.handle.get_conversation().await.len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_switch_append_ack_waits_for_persistence() {
|
||||
let mut h = TestHarness::with_manual_persistence_ack(vec![]);
|
||||
let handle = h.handle.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
handle
|
||||
.append_working_directory_switch_and_ack(
|
||||
"moved".into(),
|
||||
std::num::NonZeroU64::new(1).unwrap(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
let persistence_ack = h
|
||||
.persistence_rx
|
||||
.next_persistence_ack()
|
||||
.await
|
||||
.expect("acknowledged append requested");
|
||||
assert!(matches!(
|
||||
h.drain_persistence().as_slice(),
|
||||
[PersistenceRecord::AcknowledgedMessage(_)]
|
||||
));
|
||||
assert!(!task.is_finished(), "actor ack must wait for persistence");
|
||||
persistence_ack.send(Ok(StrictAppendAck::Appended)).unwrap();
|
||||
assert!(matches!(
|
||||
task.await.unwrap().unwrap(),
|
||||
StrictAppendAck::Appended
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_storage_result_converges_actor_memory() {
|
||||
let mut h = TestHarness::with_manual_persistence_ack(vec![]);
|
||||
let handle = h.handle.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
handle
|
||||
.append_working_directory_switch_and_ack(
|
||||
"moved".into(),
|
||||
std::num::NonZeroU64::new(2).unwrap(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
let persistence_ack = h.persistence_rx.next_persistence_ack().await.unwrap();
|
||||
persistence_ack
|
||||
.send(Err(crate::StrictAppendError::Committed {
|
||||
acknowledgement: StrictAppendAck::Appended,
|
||||
source: std::io::Error::other("summary failed"),
|
||||
}))
|
||||
.unwrap();
|
||||
let result = task.await.unwrap();
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(crate::StrictAppendError::Committed {
|
||||
acknowledgement: StrictAppendAck::Appended,
|
||||
..
|
||||
})
|
||||
));
|
||||
let conversation = h.handle.get_conversation().await;
|
||||
assert_eq!(conversation.len(), 1);
|
||||
assert_eq!(
|
||||
conversation[0].working_directory_switch_generation(),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn already_present_replaces_stale_switch_in_actor_memory() {
|
||||
let generation = NonZeroU64::new(3).unwrap();
|
||||
let mut h =
|
||||
TestHarness::with_manual_persistence_ack(vec![ConversationItem::working_directory_switch(
|
||||
"stale",
|
||||
generation.get(),
|
||||
)]);
|
||||
let handle = h.handle.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
handle
|
||||
.append_working_directory_switch_and_ack("candidate".into(), generation)
|
||||
.await
|
||||
});
|
||||
h.persistence_rx
|
||||
.next_persistence_ack()
|
||||
.await
|
||||
.unwrap()
|
||||
.send(Ok(StrictAppendAck::AlreadyPresent(
|
||||
ConversationItem::working_directory_switch("authoritative", generation.get()),
|
||||
)))
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
task.await.unwrap().unwrap(),
|
||||
StrictAppendAck::AlreadyPresent(item) if item.text_content() == "authoritative"
|
||||
));
|
||||
let conversation = h.handle.get_conversation().await;
|
||||
assert_eq!(conversation.len(), 1);
|
||||
assert_eq!(conversation[0].text_content(), "authoritative");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_already_present_replaces_retry_candidate_in_actor_memory() {
|
||||
let generation = NonZeroU64::new(4).unwrap();
|
||||
let mut h =
|
||||
TestHarness::with_manual_persistence_ack(vec![ConversationItem::working_directory_switch(
|
||||
"retry candidate",
|
||||
generation.get(),
|
||||
)]);
|
||||
let handle = h.handle.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
handle
|
||||
.append_working_directory_switch_and_ack("another retry".into(), generation)
|
||||
.await
|
||||
});
|
||||
h.persistence_rx
|
||||
.next_persistence_ack()
|
||||
.await
|
||||
.unwrap()
|
||||
.send(Err(crate::StrictAppendError::Committed {
|
||||
acknowledgement: StrictAppendAck::AlreadyPresent(
|
||||
ConversationItem::working_directory_switch("authoritative", generation.get()),
|
||||
),
|
||||
source: std::io::Error::other("summary failed"),
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
task.await.unwrap(),
|
||||
Err(crate::StrictAppendError::Committed {
|
||||
acknowledgement: StrictAppendAck::AlreadyPresent(item),
|
||||
..
|
||||
}) if item.text_content() == "authoritative"
|
||||
));
|
||||
let conversation = h.handle.get_conversation().await;
|
||||
assert_eq!(conversation.len(), 1);
|
||||
assert_eq!(conversation[0].text_content(), "authoritative");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_storage_reply_is_indeterminate_and_leaves_memory_unchanged() {
|
||||
let mut h = TestHarness::with_manual_persistence_ack(vec![]);
|
||||
let handle = h.handle.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
handle
|
||||
.append_working_directory_switch_and_ack(
|
||||
"moved".into(),
|
||||
std::num::NonZeroU64::new(2).unwrap(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
drop(h.persistence_rx.next_persistence_ack().await.unwrap());
|
||||
assert!(matches!(
|
||||
task.await.unwrap(),
|
||||
Err(crate::StrictAppendError::Indeterminate(_))
|
||||
));
|
||||
assert!(h.handle.get_conversation().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncommitted_storage_error_leaves_actor_memory_unchanged() {
|
||||
let mut h = TestHarness::with_manual_persistence_ack(vec![]);
|
||||
let handle = h.handle.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
handle
|
||||
.append_working_directory_switch_and_ack(
|
||||
"moved".into(),
|
||||
std::num::NonZeroU64::new(2).unwrap(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
let persistence_ack = h.persistence_rx.next_persistence_ack().await.unwrap();
|
||||
persistence_ack
|
||||
.send(Err(crate::StrictAppendError::NotCommitted(
|
||||
std::io::Error::other("append failed"),
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(task.await.unwrap().is_err());
|
||||
assert!(h.handle.get_conversation().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_assistant_response_appends_and_persists() {
|
||||
let mut h = TestHarness::new();
|
||||
|
|
|
|||
|
|
@ -35,6 +35,23 @@ impl std::fmt::Display for RepairHistoryBlocked {
|
|||
|
||||
impl std::error::Error for RepairHistoryBlocked {}
|
||||
|
||||
/// Result of a strict persistence-acknowledged working-directory switch append.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StrictAppendAck {
|
||||
Appended,
|
||||
AlreadyPresent(ConversationItem),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StrictAppendError {
|
||||
NotCommitted(std::io::Error),
|
||||
Committed {
|
||||
acknowledgement: StrictAppendAck,
|
||||
source: std::io::Error,
|
||||
},
|
||||
Indeterminate(std::io::Error),
|
||||
}
|
||||
|
||||
/// Commands sent to the ChatStateActor via mpsc channel.
|
||||
pub enum ChatStateCommand {
|
||||
// ═══ Mutations (fire-and-forget) ═══
|
||||
|
|
@ -48,6 +65,14 @@ pub enum ChatStateCommand {
|
|||
reply: oneshot::Sender<()>,
|
||||
},
|
||||
|
||||
/// Append one working-directory switch without repair or pruning, then
|
||||
/// acknowledge only after persistence processes the generation-aware append.
|
||||
AppendWorkingDirectorySwitchAndAck {
|
||||
content: String,
|
||||
cwd_generation: std::num::NonZeroU64,
|
||||
reply: oneshot::Sender<Result<StrictAppendAck, StrictAppendError>>,
|
||||
},
|
||||
|
||||
/// Push a user message with an explicit dangling-repair reason.
|
||||
PushUserMessageWithRepairReason {
|
||||
item: ConversationItem,
|
||||
|
|
@ -361,6 +386,12 @@ mod tests {
|
|||
item: ConversationItem::user("hello"),
|
||||
reply: tx,
|
||||
};
|
||||
let (tx, _rx) = oneshot::channel();
|
||||
let _ = ChatStateCommand::AppendWorkingDirectorySwitchAndAck {
|
||||
content: "moved".into(),
|
||||
cwd_generation: std::num::NonZeroU64::new(1).unwrap(),
|
||||
reply: tx,
|
||||
};
|
||||
let _ = ChatStateCommand::PushAssistantResponse {
|
||||
item: ConversationItem::assistant("hi"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -532,6 +532,10 @@ pub struct TodoSummary {
|
|||
/// handled by the consumer (e.g. `xai-grok-shell`), which has access to
|
||||
/// memory backends and other shell-specific dependencies.
|
||||
pub struct CompactionStateContext {
|
||||
/// Monotonic cwd generation; zero preserves the legacy compaction shape.
|
||||
pub cwd_generation: u64,
|
||||
/// Project instructions resolved for the latest destination cwd.
|
||||
pub destination_project_instructions: Option<String>,
|
||||
/// Messages since the last **real** user turn (assistant + omitted tool
|
||||
/// results). Synthetic user injections (system reminders) do not reset
|
||||
/// the boundary, preventing orphaned ToolResults in the compacted output.
|
||||
|
|
@ -555,6 +559,8 @@ pub struct CompactionStateContext {
|
|||
/// [`CompactionStateContext::build`].
|
||||
#[derive(Default)]
|
||||
pub struct CompactionInputs {
|
||||
pub cwd_generation: u64,
|
||||
pub destination_project_instructions: Option<String>,
|
||||
pub running_tasks: Vec<BackgroundTaskSummary>,
|
||||
pub running_subagents: Vec<RunningSubagentSummary>,
|
||||
pub agent_edited_paths: BTreeSet<String>,
|
||||
|
|
@ -569,6 +575,8 @@ impl CompactionStateContext {
|
|||
/// compaction boundary.
|
||||
pub async fn build(conversation: &[ConversationItem], inputs: CompactionInputs) -> Self {
|
||||
Self {
|
||||
cwd_generation: inputs.cwd_generation,
|
||||
destination_project_instructions: inputs.destination_project_instructions,
|
||||
recent_messages: extract_messages_since_last_real_user(conversation),
|
||||
last_user_query: extract_last_real_user_query(conversation),
|
||||
agent_edited_paths: inputs.agent_edited_paths.into_iter().collect(),
|
||||
|
|
@ -602,6 +610,8 @@ impl CompactionStateContext {
|
|||
/// `recent_messages` so the model keeps verbatim tool context.
|
||||
pub fn for_compaction(&self) -> Self {
|
||||
Self {
|
||||
cwd_generation: self.cwd_generation,
|
||||
destination_project_instructions: self.destination_project_instructions.clone(),
|
||||
recent_messages: Vec::new(),
|
||||
last_user_query: self.last_user_query.clone(),
|
||||
agent_edited_paths: self.agent_edited_paths.clone(),
|
||||
|
|
@ -839,7 +849,15 @@ pub fn build_compacted_history(input: CompactedHistoryInput<'_>) -> Vec<Conversa
|
|||
input.system_message,
|
||||
ConversationItem::user_meta(input.user_message_prefix),
|
||||
];
|
||||
if let Some(ref reminder) = input.agents_md_reminder {
|
||||
let project_instructions = if input.state_context.cwd_generation == 0 {
|
||||
input.agents_md_reminder.as_ref()
|
||||
} else {
|
||||
input
|
||||
.state_context
|
||||
.destination_project_instructions
|
||||
.as_ref()
|
||||
};
|
||||
if let Some(reminder) = project_instructions {
|
||||
compacted.push(ConversationItem::project_instructions(reminder.clone()));
|
||||
}
|
||||
if let Some(ref last_query) = input.state_context.last_user_query {
|
||||
|
|
@ -2852,21 +2870,94 @@ The user asked to read main.rs and lib.rs. main.rs prints hello world, lib.rs ha
|
|||
"system-reminder should list edited files"
|
||||
);
|
||||
}
|
||||
/// End-to-end test: build a compacted history with realistic file re-reads
|
||||
/// (line-numbered content matching extract_file_content_lines output),
|
||||
/// a truncated file, and a too-large file reference.
|
||||
///
|
||||
/// Verifies the exact string content of every message in the compacted
|
||||
/// history, including the plain-text "Called the read_file tool..." /
|
||||
/// AGENTS.md slot in the post-compaction conversation must be tagged
|
||||
/// `SyntheticReason::ProjectInstructions`, NOT `CompactionMeta`. This is
|
||||
/// the contract `spawn_session_actor`'s idempotence guard relies on when
|
||||
/// a session is resumed from a post-compaction `chat_history.jsonl`: it
|
||||
/// skips re-inserting AGENTS.md when it sees a tagged `User` item,
|
||||
/// preserving the KV-cache invariant.
|
||||
/// Generation zero ignores relocation-only fields and preserves legacy output.
|
||||
#[test]
|
||||
fn generation_zero_compaction_keeps_legacy_project_instructions() {
|
||||
let state_context = CompactionStateContext {
|
||||
cwd_generation: 0,
|
||||
destination_project_instructions: Some("destination rules".into()),
|
||||
recent_messages: vec![],
|
||||
last_user_query: None,
|
||||
agent_edited_paths: vec![],
|
||||
running_tasks: vec![],
|
||||
running_subagents: vec![],
|
||||
connected_mcp_servers: vec![],
|
||||
todos: vec![],
|
||||
};
|
||||
let compacted = build_compacted_history(CompactedHistoryInput {
|
||||
system_message: ConversationItem::system("sys"),
|
||||
user_message_prefix: "prefix".into(),
|
||||
agents_md_reminder: Some("startup rules".into()),
|
||||
state_context: &state_context,
|
||||
compaction_summary: "summary".into(),
|
||||
system_reminder: None,
|
||||
summary_before_recent: false,
|
||||
transcript_hint: None,
|
||||
summary_count: 1,
|
||||
});
|
||||
assert_eq!(compacted[2].text_content(), "startup rules");
|
||||
}
|
||||
#[test]
|
||||
fn relocated_compaction_uses_destination_project_instructions() {
|
||||
let state_context = CompactionStateContext {
|
||||
cwd_generation: 1,
|
||||
destination_project_instructions: Some("destination rules".into()),
|
||||
recent_messages: vec![],
|
||||
last_user_query: None,
|
||||
agent_edited_paths: vec![],
|
||||
running_tasks: vec![],
|
||||
running_subagents: vec![],
|
||||
connected_mcp_servers: vec![],
|
||||
todos: vec![],
|
||||
};
|
||||
let compacted = build_compacted_history(CompactedHistoryInput {
|
||||
system_message: ConversationItem::system("sys"),
|
||||
user_message_prefix: "prefix".into(),
|
||||
agents_md_reminder: Some("startup rules".into()),
|
||||
state_context: &state_context,
|
||||
compaction_summary: "summary".into(),
|
||||
system_reminder: None,
|
||||
summary_before_recent: false,
|
||||
transcript_hint: None,
|
||||
summary_count: 1,
|
||||
});
|
||||
assert_eq!(compacted[2].text_content(), "destination rules");
|
||||
}
|
||||
#[test]
|
||||
fn relocated_compaction_does_not_restore_source_instructions_when_destination_has_none() {
|
||||
let state_context = CompactionStateContext {
|
||||
cwd_generation: 1,
|
||||
destination_project_instructions: None,
|
||||
recent_messages: vec![],
|
||||
last_user_query: None,
|
||||
agent_edited_paths: vec![],
|
||||
running_tasks: vec![],
|
||||
running_subagents: vec![],
|
||||
connected_mcp_servers: vec![],
|
||||
todos: vec![],
|
||||
};
|
||||
let compacted = build_compacted_history(CompactedHistoryInput {
|
||||
system_message: ConversationItem::system("sys"),
|
||||
user_message_prefix: "prefix".into(),
|
||||
agents_md_reminder: Some("source rules".into()),
|
||||
state_context: &state_context,
|
||||
compaction_summary: "summary".into(),
|
||||
system_reminder: None,
|
||||
summary_before_recent: false,
|
||||
transcript_hint: None,
|
||||
summary_count: 1,
|
||||
});
|
||||
assert!(!compacted.iter().any(|item| {
|
||||
matches!(item, ConversationItem::User(user)
|
||||
if user.synthetic_reason == Some(SyntheticReason::ProjectInstructions))
|
||||
}));
|
||||
}
|
||||
/// The AGENTS.md slot must use the structural project-instructions tag.
|
||||
#[test]
|
||||
fn build_compacted_history_tags_agents_md_with_project_instructions() {
|
||||
let state_context = CompactionStateContext {
|
||||
cwd_generation: 0,
|
||||
destination_project_instructions: None,
|
||||
recent_messages: vec![],
|
||||
last_user_query: None,
|
||||
agent_edited_paths: vec![],
|
||||
|
|
@ -2908,6 +2999,8 @@ The user asked to read main.rs and lib.rs. main.rs prints hello world, lib.rs ha
|
|||
#[test]
|
||||
fn build_compacted_history_omits_agents_md_when_none() {
|
||||
let state_context = CompactionStateContext {
|
||||
cwd_generation: 0,
|
||||
destination_project_instructions: None,
|
||||
recent_messages: vec![],
|
||||
last_user_query: None,
|
||||
agent_edited_paths: vec![],
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use xai_grok_sampling_types::{
|
|||
ToolSpec, TraceContext,
|
||||
};
|
||||
|
||||
use crate::commands::{ChatStateCommand, RepairHistoryBlocked};
|
||||
use crate::commands::{ChatStateCommand, RepairHistoryBlocked, StrictAppendAck, StrictAppendError};
|
||||
use crate::types::{
|
||||
AutoCompactTrigger, ChatStateSnapshot, ConversationCounts, Credentials, NotificationMeta,
|
||||
TurnCapture,
|
||||
|
|
@ -50,6 +50,29 @@ impl ChatStateHandle {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Strictly append one working-directory switch and await persistence.
|
||||
/// A matching generation returns `AlreadyPresent`; indeterminate errors must be retried.
|
||||
pub async fn append_working_directory_switch_and_ack(
|
||||
&self,
|
||||
content: String,
|
||||
cwd_generation: std::num::NonZeroU64,
|
||||
) -> Result<StrictAppendAck, StrictAppendError> {
|
||||
self.query("AppendWorkingDirectorySwitchAndAck", |reply| {
|
||||
ChatStateCommand::AppendWorkingDirectorySwitchAndAck {
|
||||
content,
|
||||
cwd_generation,
|
||||
reply,
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|| {
|
||||
Err(StrictAppendError::Indeterminate(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"chat-state actor unavailable; retry by generation",
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Push a user message with an explicit dangling-repair reason.
|
||||
pub fn push_user_message_with_repair_reason(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ pub use actor::state::{
|
|||
estimate_system_message_tokens, estimate_tool_definition_tokens,
|
||||
estimate_tool_definitions_tokens,
|
||||
};
|
||||
pub use commands::ModelMetadata;
|
||||
pub use commands::{ModelMetadata, StrictAppendAck, StrictAppendError};
|
||||
pub use compaction_mode::CompactionMode;
|
||||
pub use compaction_transcript::CompactionDetail;
|
||||
pub use events::ChatStateEvent;
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@
|
|||
//! The mock uses a channel to report records to the test, keeping everything
|
||||
//! in the actor / message-passing paradigm.
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use std::io;
|
||||
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use xai_grok_sampling_types::ConversationItem;
|
||||
|
||||
use crate::commands::{StrictAppendAck, StrictAppendError};
|
||||
|
||||
/// Abstraction over chat-specific persistence operations.
|
||||
///
|
||||
/// The actor owns this exclusively via `Box<dyn ChatPersistence>`, so all
|
||||
|
|
@ -20,6 +24,12 @@ pub trait ChatPersistence: Send + 'static {
|
|||
/// Persist a single conversation item (append to chat_history.jsonl).
|
||||
fn persist_message(&mut self, item: &ConversationItem);
|
||||
|
||||
/// Persist one working-directory switch generation and report commit status.
|
||||
fn persist_working_directory_switch_and_ack(
|
||||
&mut self,
|
||||
item: &ConversationItem,
|
||||
) -> oneshot::Receiver<Result<StrictAppendAck, StrictAppendError>>;
|
||||
|
||||
/// Replace the entire chat history (compaction / rewind).
|
||||
fn replace_history(&mut self, items: &[ConversationItem]);
|
||||
|
||||
|
|
@ -36,6 +46,8 @@ pub trait ChatPersistence: Send + 'static {
|
|||
pub enum PersistenceRecord {
|
||||
/// A single message was persisted.
|
||||
Message(ConversationItem),
|
||||
/// A persistence-acknowledged switch append was requested.
|
||||
AcknowledgedMessage(ConversationItem),
|
||||
/// The full history was replaced.
|
||||
ReplaceHistory(Vec<ConversationItem>),
|
||||
/// A flush was requested.
|
||||
|
|
@ -47,11 +59,17 @@ pub enum PersistenceRecord {
|
|||
/// the actor did. No locks, no atomics — just message passing.
|
||||
pub struct MockChatPersistence {
|
||||
tx: mpsc::UnboundedSender<PersistenceRecord>,
|
||||
persistence_ack_tx:
|
||||
Option<mpsc::UnboundedSender<oneshot::Sender<Result<StrictAppendAck, StrictAppendError>>>>,
|
||||
persisted_working_directory_switches: Vec<ConversationItem>,
|
||||
}
|
||||
|
||||
/// Receiver side of the mock. Held by the test to drain and inspect records.
|
||||
pub struct MockPersistenceReceiver {
|
||||
rx: mpsc::UnboundedReceiver<PersistenceRecord>,
|
||||
persistence_ack_rx: Option<
|
||||
mpsc::UnboundedReceiver<oneshot::Sender<Result<StrictAppendAck, StrictAppendError>>>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl MockChatPersistence {
|
||||
|
|
@ -59,7 +77,34 @@ impl MockChatPersistence {
|
|||
/// receiver in the test.
|
||||
pub fn new() -> (Self, MockPersistenceReceiver) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(Self { tx }, MockPersistenceReceiver { rx })
|
||||
(
|
||||
Self {
|
||||
tx,
|
||||
persistence_ack_tx: None,
|
||||
persisted_working_directory_switches: Vec::new(),
|
||||
},
|
||||
MockPersistenceReceiver {
|
||||
rx,
|
||||
persistence_ack_rx: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a mock whose persistence acknowledgement is test-controlled.
|
||||
pub fn new_with_manual_persistence_ack() -> (Self, MockPersistenceReceiver) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let (persistence_ack_tx, persistence_ack_rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
Self {
|
||||
tx,
|
||||
persistence_ack_tx: Some(persistence_ack_tx),
|
||||
persisted_working_directory_switches: Vec::new(),
|
||||
},
|
||||
MockPersistenceReceiver {
|
||||
rx,
|
||||
persistence_ack_rx: Some(persistence_ack_rx),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +118,16 @@ impl MockPersistenceReceiver {
|
|||
records
|
||||
}
|
||||
|
||||
/// Receive the next manual persistence acknowledgement sender.
|
||||
pub async fn next_persistence_ack(
|
||||
&mut self,
|
||||
) -> Option<oneshot::Sender<Result<StrictAppendAck, StrictAppendError>>> {
|
||||
match &mut self.persistence_ack_rx {
|
||||
Some(rx) => rx.recv().await,
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all `Message` items received so far (drains the channel).
|
||||
pub fn messages(&mut self) -> Vec<ConversationItem> {
|
||||
self.drain()
|
||||
|
|
@ -90,6 +145,40 @@ impl ChatPersistence for MockChatPersistence {
|
|||
let _ = self.tx.send(PersistenceRecord::Message(item.clone()));
|
||||
}
|
||||
|
||||
fn persist_working_directory_switch_and_ack(
|
||||
&mut self,
|
||||
item: &ConversationItem,
|
||||
) -> oneshot::Receiver<Result<StrictAppendAck, StrictAppendError>> {
|
||||
let (reply, receiver) = oneshot::channel();
|
||||
let sent = self
|
||||
.tx
|
||||
.send(PersistenceRecord::AcknowledgedMessage(item.clone()))
|
||||
.map_err(|_| {
|
||||
StrictAppendError::NotCommitted(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"mock persistence closed",
|
||||
))
|
||||
});
|
||||
if let Err(error) = sent {
|
||||
let _ = reply.send(Err(error));
|
||||
} else if let Some(ack_tx) = &self.persistence_ack_tx {
|
||||
let _ = ack_tx.send(reply);
|
||||
} else {
|
||||
let generation = item.working_directory_switch_generation();
|
||||
let acknowledgement = self
|
||||
.persisted_working_directory_switches
|
||||
.iter()
|
||||
.find(|persisted| persisted.working_directory_switch_generation() == generation)
|
||||
.cloned()
|
||||
.map_or(StrictAppendAck::Appended, StrictAppendAck::AlreadyPresent);
|
||||
if matches!(&acknowledgement, StrictAppendAck::Appended) {
|
||||
self.persisted_working_directory_switches.push(item.clone());
|
||||
}
|
||||
let _ = reply.send(Ok(acknowledgement));
|
||||
}
|
||||
receiver
|
||||
}
|
||||
|
||||
fn replace_history(&mut self, items: &[ConversationItem]) {
|
||||
let _ = self
|
||||
.tx
|
||||
|
|
@ -110,6 +199,14 @@ pub struct NullChatPersistence;
|
|||
|
||||
impl ChatPersistence for NullChatPersistence {
|
||||
fn persist_message(&mut self, _item: &ConversationItem) {}
|
||||
fn persist_working_directory_switch_and_ack(
|
||||
&mut self,
|
||||
_item: &ConversationItem,
|
||||
) -> oneshot::Receiver<Result<StrictAppendAck, StrictAppendError>> {
|
||||
let (reply, receiver) = oneshot::channel();
|
||||
let _ = reply.send(Ok(StrictAppendAck::Appended));
|
||||
receiver
|
||||
}
|
||||
fn replace_history(&mut self, _items: &[ConversationItem]) {}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
|
|
@ -163,6 +260,28 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_persistence_deduplicates_working_directory_generation() {
|
||||
let (mut mock, _rx) = MockChatPersistence::new();
|
||||
let first = ConversationItem::working_directory_switch("authoritative", 4);
|
||||
assert!(matches!(
|
||||
mock.persist_working_directory_switch_and_ack(&first)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
StrictAppendAck::Appended
|
||||
));
|
||||
assert!(matches!(
|
||||
mock.persist_working_directory_switch_and_ack(
|
||||
&ConversationItem::working_directory_switch("retry", 4),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
StrictAppendAck::AlreadyPresent(item) if item.text_content() == "authoritative"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_persistence_does_not_panic() {
|
||||
let mut null = NullChatPersistence;
|
||||
|
|
|
|||
Loading…
Reference in a new issue