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:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -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);
}

View file

@ -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(_));

View file

@ -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

View file

@ -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();

View file

@ -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"),
};

View file

@ -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![],

View file

@ -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,

View file

@ -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;

View file

@ -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;