mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-30 16:55:49 +08:00
Classify saved sessions by live work rather than pane existence
Operator status previously treated any tmux pane in a workspace as equivalent to active work. The new classifier uses tmux pane command/path metadata as a soft signal, treats plain shells as idle, and adds dirty-worktree abandoned markers to status and session-list output for clawhip consumers. Constraint: Keep issue #320 prototype minimal and additive without new dependencies Rejected: Screen-scraping pane output | fragile and broader than needed for lifecycle classification Confidence: high Scope-risk: narrow Tested: cargo test -p rusty-claude-cli Tested: cargo check -p rusty-claude-cli Not-tested: cargo clippy -p rusty-claude-cli --all-targets -- -D warnings is blocked by pre-existing commands crate clippy::unnecessary_wraps warnings
This commit is contained in:
parent
cb56dc12ab
commit
be53e04671
@ -1961,6 +1961,7 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
session_lifecycle: classify_session_lifecycle_for(&cwd),
|
||||
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
|
||||
// Doctor path has its own config check; StatusContext here is only
|
||||
// fed into health renderers that don't read config_load_error.
|
||||
@ -2805,6 +2806,7 @@ struct StatusContext {
|
||||
project_root: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
git_summary: GitWorkspaceSummary,
|
||||
session_lifecycle: SessionLifecycleSummary,
|
||||
sandbox_status: runtime::SandboxStatus,
|
||||
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
|
||||
/// we capture the parse error here and still populate every field that
|
||||
@ -2834,6 +2836,75 @@ struct GitWorkspaceSummary {
|
||||
conflicted_files: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SessionLifecycleKind {
|
||||
RunningProcess,
|
||||
IdleShell,
|
||||
SavedOnly,
|
||||
}
|
||||
|
||||
impl SessionLifecycleKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::RunningProcess => "running_process",
|
||||
Self::IdleShell => "idle_shell",
|
||||
Self::SavedOnly => "saved_only",
|
||||
}
|
||||
}
|
||||
|
||||
fn human_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::RunningProcess => "running process",
|
||||
Self::IdleShell => "idle shell",
|
||||
Self::SavedOnly => "saved only",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind,
|
||||
pane_id: Option<String>,
|
||||
pane_command: Option<String>,
|
||||
pane_path: Option<PathBuf>,
|
||||
workspace_dirty: bool,
|
||||
abandoned: bool,
|
||||
}
|
||||
|
||||
impl SessionLifecycleSummary {
|
||||
fn signal(&self) -> String {
|
||||
let mut parts = vec![self.kind.human_label().to_string()];
|
||||
if self.workspace_dirty {
|
||||
parts.push("dirty worktree".to_string());
|
||||
}
|
||||
if self.abandoned {
|
||||
parts.push("abandoned?".to_string());
|
||||
}
|
||||
if let Some(command) = self.pane_command.as_deref() {
|
||||
parts.push(format!("cmd={command}"));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"kind": self.kind.as_str(),
|
||||
"pane_id": self.pane_id,
|
||||
"pane_command": self.pane_command,
|
||||
"pane_path": self.pane_path.as_ref().map(|path| path.display().to_string()),
|
||||
"workspace_dirty": self.workspace_dirty,
|
||||
"abandoned": self.abandoned,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TmuxPaneSnapshot {
|
||||
pane_id: String,
|
||||
current_command: String,
|
||||
current_path: PathBuf,
|
||||
}
|
||||
|
||||
impl GitWorkspaceSummary {
|
||||
fn is_clean(self) -> bool {
|
||||
self.changed_files == 0
|
||||
@ -2865,6 +2936,120 @@ impl GitWorkspaceSummary {
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
|
||||
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
|
||||
}
|
||||
|
||||
fn classify_session_lifecycle_from_panes(
|
||||
workspace: &Path,
|
||||
panes: Vec<TmuxPaneSnapshot>,
|
||||
) -> SessionLifecycleSummary {
|
||||
let workspace_dirty = git_worktree_is_dirty(workspace);
|
||||
let mut idle_shell = None;
|
||||
for pane in panes {
|
||||
if !pane_path_matches_workspace(&pane.current_path, workspace) {
|
||||
continue;
|
||||
}
|
||||
if is_idle_shell_command(&pane.current_command) {
|
||||
idle_shell.get_or_insert(pane);
|
||||
} else {
|
||||
return SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pane) = idle_shell {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::IdleShell,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
} else {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::SavedOnly,
|
||||
pane_id: None,
|
||||
pane_command: None,
|
||||
pane_path: None,
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
|
||||
let output = Command::new("tmux")
|
||||
.args([
|
||||
"list-panes",
|
||||
"-a",
|
||||
"-F",
|
||||
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
|
||||
])
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_tmux_pane_snapshots(&stdout)
|
||||
}
|
||||
|
||||
fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut fields = line.splitn(3, '\t');
|
||||
let pane_id = fields.next()?.trim();
|
||||
let current_command = fields.next()?.trim();
|
||||
let current_path = fields.next()?.trim();
|
||||
if pane_id.is_empty() || current_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(TmuxPaneSnapshot {
|
||||
pane_id: pane_id.to_string(),
|
||||
current_command: current_command.to_string(),
|
||||
current_path: PathBuf::from(current_path),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
|
||||
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
|
||||
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
||||
pane_path == workspace || pane_path.starts_with(&workspace)
|
||||
}
|
||||
|
||||
fn is_idle_shell_command(command: &str) -> bool {
|
||||
let command = command.rsplit('/').next().unwrap_or(command);
|
||||
matches!(
|
||||
command,
|
||||
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
|
||||
)
|
||||
}
|
||||
|
||||
fn git_worktree_is_dirty(workspace: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args(["status", "--porcelain"])
|
||||
.output();
|
||||
output
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.is_some_and(|output| !output.stdout.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn format_unknown_slash_command_message(name: &str) -> String {
|
||||
let suggestions = suggest_slash_commands(name);
|
||||
@ -3407,6 +3592,18 @@ fn run_resume_command(
|
||||
} if act == "list" => {
|
||||
let sessions = list_managed_sessions().unwrap_or_default();
|
||||
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
|
||||
let session_details: Vec<serde_json::Value> = sessions
|
||||
.iter()
|
||||
.map(|session| {
|
||||
serde_json::json!({
|
||||
"id": session.id,
|
||||
"path": session.path.display().to_string(),
|
||||
"message_count": session.message_count,
|
||||
"updated_at_ms": session.updated_at_ms,
|
||||
"lifecycle": session.lifecycle.json_value(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let active_id = session.session_id.clone();
|
||||
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
|
||||
Ok(ResumeCommandOutcome {
|
||||
@ -3415,6 +3612,7 @@ fn run_resume_command(
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "session_list",
|
||||
"sessions": session_ids,
|
||||
"session_details": session_details,
|
||||
"active": active_id,
|
||||
})),
|
||||
})
|
||||
@ -3646,6 +3844,7 @@ struct ManagedSessionSummary {
|
||||
message_count: usize,
|
||||
parent_session_id: Option<String>,
|
||||
branch_name: Option<String>,
|
||||
lifecycle: SessionLifecycleSummary,
|
||||
}
|
||||
|
||||
struct LiveCli {
|
||||
@ -5251,7 +5450,9 @@ fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std
|
||||
}
|
||||
|
||||
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
||||
Ok(current_session_store()?
|
||||
let store = current_session_store()?;
|
||||
let lifecycle = classify_session_lifecycle_for(store.workspace_root());
|
||||
Ok(store
|
||||
.list_sessions()
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
|
||||
.into_iter()
|
||||
@ -5263,12 +5464,15 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle: lifecycle.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
||||
let session = current_session_store()?
|
||||
let store = current_session_store()?;
|
||||
let lifecycle = classify_session_lifecycle_for(store.workspace_root());
|
||||
let session = store
|
||||
.latest_session()
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
Ok(ManagedSessionSummary {
|
||||
@ -5279,6 +5483,7 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle,
|
||||
})
|
||||
}
|
||||
|
||||
@ -5343,8 +5548,9 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
||||
(None, None) => String::new(),
|
||||
};
|
||||
lines.push(format!(
|
||||
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
|
||||
" {id:<20} {marker:<10} lifecycle={lifecycle} msgs={msgs:<4} modified={modified}{lineage} path={path}",
|
||||
id = session.id,
|
||||
lifecycle = session.lifecycle.signal(),
|
||||
msgs = session.message_count,
|
||||
modified = format_session_modified_age(session.modified_epoch_millis),
|
||||
lineage = lineage,
|
||||
@ -5527,6 +5733,7 @@ fn status_json_value(
|
||||
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
|
||||
path.file_stem().map(|n| n.to_string_lossy().into_owned())
|
||||
}),
|
||||
"session_lifecycle": context.session_lifecycle.json_value(),
|
||||
"loaded_config_files": context.loaded_config_files,
|
||||
"discovered_config_files": context.discovered_config_files,
|
||||
"memory_file_count": context.memory_file_count,
|
||||
@ -5582,7 +5789,7 @@ fn status_context(
|
||||
parse_git_status_metadata(project_context.git_status.as_deref());
|
||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||
Ok(StatusContext {
|
||||
cwd,
|
||||
cwd: cwd.clone(),
|
||||
session_path: session_path.map(Path::to_path_buf),
|
||||
loaded_config_files,
|
||||
discovered_config_files,
|
||||
@ -5590,6 +5797,7 @@ fn status_context(
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
session_lifecycle: classify_session_lifecycle_for(&cwd),
|
||||
sandbox_status,
|
||||
config_load_error,
|
||||
})
|
||||
@ -5663,6 +5871,7 @@ fn format_status_report(
|
||||
Unstaged {}
|
||||
Untracked {}
|
||||
Session {}
|
||||
Lifecycle {}
|
||||
Config files loaded {}/{}
|
||||
Memory files {}
|
||||
Suggested flow /status → /diff → /commit",
|
||||
@ -5681,6 +5890,7 @@ fn format_status_report(
|
||||
|| "live-repl".to_string(),
|
||||
|path| path.display().to_string()
|
||||
),
|
||||
context.session_lifecycle.signal(),
|
||||
context.loaded_config_files,
|
||||
context.discovered_config_files,
|
||||
context.memory_file_count,
|
||||
@ -9025,10 +9235,10 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
||||
mod tests {
|
||||
use super::{
|
||||
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
||||
classify_error_kind, collect_session_prompt_history, create_managed_session_handle,
|
||||
describe_tool_progress, filter_tool_specs, format_bughunter_report,
|
||||
format_commit_preflight_report, format_commit_skipped_report, format_compact_report,
|
||||
format_connected_line, format_cost_report, format_history_timestamp,
|
||||
classify_error_kind, classify_session_lifecycle_from_panes, collect_session_prompt_history,
|
||||
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
||||
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
|
||||
format_compact_report, format_connected_line, format_cost_report, format_history_timestamp,
|
||||
format_internal_prompt_progress_line, format_issue_report, format_model_report,
|
||||
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
||||
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
|
||||
@ -9039,14 +9249,15 @@ mod tests {
|
||||
parse_history_count, permission_policy, print_help_to, push_output_block,
|
||||
render_config_report, render_diff_report, render_diff_report_for, render_help_topic,
|
||||
render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage,
|
||||
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
|
||||
resolve_repl_model, resolve_session_reference, response_to_events,
|
||||
resume_supported_slash_commands, run_resume_command, short_tool_id,
|
||||
render_session_list, render_session_markdown, resolve_model_alias,
|
||||
resolve_model_alias_with_config, resolve_repl_model, resolve_session_reference,
|
||||
response_to_events, resume_supported_slash_commands, run_resume_command, short_tool_id,
|
||||
slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
|
||||
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
|
||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||
PromptHistoryEntry, SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
|
||||
status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt,
|
||||
validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor,
|
||||
GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli,
|
||||
LocalHelpTopic, PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary,
|
||||
SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
|
||||
STUB_COMMANDS,
|
||||
};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
@ -11637,6 +11848,14 @@ mod tests {
|
||||
untracked_files: 1,
|
||||
conflicted_files: 0,
|
||||
},
|
||||
session_lifecycle: SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::IdleShell,
|
||||
pane_id: Some("%7".to_string()),
|
||||
pane_command: Some("zsh".to_string()),
|
||||
pane_path: Some(PathBuf::from("/tmp/project")),
|
||||
workspace_dirty: true,
|
||||
abandoned: true,
|
||||
},
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
config_load_error: None,
|
||||
},
|
||||
@ -11659,11 +11878,149 @@ mod tests {
|
||||
assert!(status.contains("Unstaged 1"));
|
||||
assert!(status.contains("Untracked 1"));
|
||||
assert!(status.contains("Session session.jsonl"));
|
||||
assert!(
|
||||
status.contains("Lifecycle idle shell · dirty worktree · abandoned? · cmd=zsh")
|
||||
);
|
||||
assert!(status.contains("Config files loaded 2/3"));
|
||||
assert!(status.contains("Memory files 4"));
|
||||
assert!(status.contains("Suggested flow /status → /diff → /commit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_lifecycle_prefers_running_process_over_idle_shell() {
|
||||
let workspace = PathBuf::from("/tmp/project");
|
||||
let lifecycle = classify_session_lifecycle_from_panes(
|
||||
&workspace,
|
||||
vec![
|
||||
TmuxPaneSnapshot {
|
||||
pane_id: "%1".to_string(),
|
||||
current_command: "zsh".to_string(),
|
||||
current_path: workspace.clone(),
|
||||
},
|
||||
TmuxPaneSnapshot {
|
||||
pane_id: "%2".to_string(),
|
||||
current_command: "claw".to_string(),
|
||||
current_path: workspace.join("rust"),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(lifecycle.kind, SessionLifecycleKind::RunningProcess);
|
||||
assert_eq!(lifecycle.pane_id.as_deref(), Some("%2"));
|
||||
assert_eq!(lifecycle.pane_command.as_deref(), Some("claw"));
|
||||
assert!(!lifecycle.abandoned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_lifecycle_marks_dirty_idle_shell_as_abandoned() {
|
||||
let _guard = env_lock();
|
||||
let workspace = temp_workspace("dirty-idle-shell");
|
||||
fs::create_dir_all(&workspace).expect("workspace should create");
|
||||
git(&["init", "--quiet"], &workspace);
|
||||
git(&["config", "user.email", "tests@example.com"], &workspace);
|
||||
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
|
||||
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
|
||||
git(&["add", "tracked.txt"], &workspace);
|
||||
git(&["commit", "-m", "init", "--quiet"], &workspace);
|
||||
fs::write(workspace.join("tracked.txt"), "hello\nchanged\n").expect("dirty tracked");
|
||||
|
||||
let lifecycle = classify_session_lifecycle_from_panes(
|
||||
&workspace,
|
||||
vec![TmuxPaneSnapshot {
|
||||
pane_id: "%3".to_string(),
|
||||
current_command: "bash".to_string(),
|
||||
current_path: workspace.clone(),
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(lifecycle.kind, SessionLifecycleKind::IdleShell);
|
||||
assert!(lifecycle.workspace_dirty);
|
||||
assert!(lifecycle.abandoned);
|
||||
|
||||
fs::remove_dir_all(workspace).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_list_surfaces_saved_dirty_abandoned_lifecycle() {
|
||||
let _guard = cwd_guard();
|
||||
let workspace = temp_workspace("session-list-lifecycle");
|
||||
fs::create_dir_all(&workspace).expect("workspace should create");
|
||||
git(&["init", "--quiet"], &workspace);
|
||||
git(&["config", "user.email", "tests@example.com"], &workspace);
|
||||
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
|
||||
fs::write(workspace.join(".gitignore"), ".claw/\n").expect("write gitignore");
|
||||
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
|
||||
git(&["add", ".gitignore", "tracked.txt"], &workspace);
|
||||
git(&["commit", "-m", "init", "--quiet"], &workspace);
|
||||
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&workspace).expect("switch cwd");
|
||||
let handle = create_managed_session_handle("session-alpha").expect("session handle");
|
||||
Session::new()
|
||||
.with_workspace_root(workspace.clone())
|
||||
.with_persistence_path(handle.path.clone())
|
||||
.save_to_path(&handle.path)
|
||||
.expect("session should save");
|
||||
fs::write(workspace.join("tracked.txt"), "hello\nchanged\n").expect("dirty tracked");
|
||||
|
||||
let report = render_session_list("session-alpha").expect("session list should render");
|
||||
|
||||
assert!(report.contains("session-alpha"));
|
||||
assert!(report.contains("lifecycle=saved only · dirty worktree · abandoned?"));
|
||||
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
fs::remove_dir_all(workspace).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_json_surfaces_session_lifecycle_for_clawhip() {
|
||||
let context = super::StatusContext {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
session_path: None,
|
||||
loaded_config_files: 0,
|
||||
discovered_config_files: 0,
|
||||
memory_file_count: 0,
|
||||
project_root: Some(PathBuf::from("/tmp/project")),
|
||||
git_branch: Some("feature/session-lifecycle".to_string()),
|
||||
git_summary: GitWorkspaceSummary::default(),
|
||||
session_lifecycle: SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some("%9".to_string()),
|
||||
pane_command: Some("claw".to_string()),
|
||||
pane_path: Some(PathBuf::from("/tmp/project")),
|
||||
workspace_dirty: false,
|
||||
abandoned: false,
|
||||
},
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
config_load_error: None,
|
||||
};
|
||||
|
||||
let value = status_json_value(
|
||||
Some("claude-sonnet"),
|
||||
StatusUsage {
|
||||
message_count: 0,
|
||||
turns: 0,
|
||||
latest: runtime::TokenUsage::default(),
|
||||
cumulative: runtime::TokenUsage::default(),
|
||||
estimated_tokens: 0,
|
||||
},
|
||||
"workspace-write",
|
||||
&context,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
value["workspace"]["session_lifecycle"]["kind"],
|
||||
"running_process"
|
||||
);
|
||||
assert_eq!(
|
||||
value["workspace"]["session_lifecycle"]["pane_command"],
|
||||
"claw"
|
||||
);
|
||||
assert_eq!(value["workspace"]["session_lifecycle"]["abandoned"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_reports_surface_workspace_context() {
|
||||
let summary = GitWorkspaceSummary {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user