diff --git a/c/Makefile b/c/Makefile index 3a185ba..b4c354e 100644 --- a/c/Makefile +++ b/c/Makefile @@ -9,7 +9,7 @@ VENDOR_SRCS = vendor/linenoise/linenoise.c OBJS = $(patsubst %.c,$(BUILDDIR)/%.o,$(SRCS)) $(patsubst %.c,$(BUILDDIR)/%.o,$(VENDOR_SRCS)) TARGET = ../dist/cagent -.PHONY: all clean +.PHONY: all clean test-continue all: $(TARGET) @@ -32,13 +32,17 @@ $(BUILDDIR)/%.o: %.c tools_embed.h $(CC) $(CFLAGS) -c -o $@ $< clean: - rm -rf $(BUILDDIR) $(TARGET) tools_embed.h test_classify + rm -rf $(BUILDDIR) $(TARGET) tools_embed.h test_classify test_continue test-classify: test_classify.c $(MAKE) $(BUILDDIR)/tools_test.o CFLAGS="$(CFLAGS) -DTEST_CLASSIFY" OBJS="$(filter-out $(BUILDDIR)/cagent.o $(BUILDDIR)/tools.o,$(OBJS)) $(BUILDDIR)/tools_test.o" $(CC) $(CFLAGS) -DTEST_CLASSIFY -o test_classify test_classify.c $(filter-out $(BUILDDIR)/cagent.o $(BUILDDIR)/tools.o,$(OBJS)) $(BUILDDIR)/tools_test.o $(LDFLAGS) @./test_classify +test-continue: test_continue.c store.c util.c json.c + $(CC) $(CFLAGS) -o test_continue test_continue.c store.c util.c json.c + @./test_continue + $(BUILDDIR)/tools_test.o: tools.c tools.h agent.h msgqueue.h protocol.h json.h util.h $(CC) $(CFLAGS) -DTEST_CLASSIFY -c -o $@ tools.c diff --git a/c/store.c b/c/store.c index e8a1346..77660bf 100644 --- a/c/store.c +++ b/c/store.c @@ -228,7 +228,7 @@ char *store_session_resolve_continue(const char *home, const char *cwd) { struct dirent *entry; while ((entry = readdir(dir)) != NULL) { - if (entry->d_name[0] == '.') continue; + if (entry->d_name[0] == '.' || strncmp(entry->d_name, "sub_", 4) == 0) continue; /* 尝试解析目录名为时间戳: YYYYMMDD-HHMMSS-XXXX */ struct stat st; sb_truncate(&buf, 0); diff --git a/c/test_continue.c b/c/test_continue.c new file mode 100644 index 0000000..15c2241 --- /dev/null +++ b/c/test_continue.c @@ -0,0 +1,115 @@ +#include "store.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include + +static int failures = 0; + +static void check(int condition, const char *message) { + if (condition) { + printf("PASS: %s\n", message); + } else { + fprintf(stderr, "FAIL: %s\n", message); + failures++; + } +} + +static char *make_temp_dir(const char *label) { + char *path = NULL; + if (asprintf(&path, "/tmp/cagent-%s-XXXXXX", label) < 0) return NULL; + if (!mkdtemp(path)) { + free(path); + return NULL; + } + return path; +} + +static void set_mtime(const char *path, time_t when) { + struct timeval times[2] = {{when, 0}, {when, 0}}; + if (utimes(path, times) != 0) { + perror("utimes"); + exit(1); + } +} + +static void test_continue_skips_sub_sessions(void) { + char *home = make_temp_dir("continue-skip-sub"); + char *cwd = util_path_join(home, "project"); + util_mkdirs(cwd, 0755); + char *key = store_session_project_key(cwd); + char *project_dir = NULL; + asprintf(&project_dir, "%s/.bash-agent/projects/%s", home, key); + char *normal_dir = util_path_join(project_dir, "normal-session"); + char *sub_dir = util_path_join(project_dir, "sub_latest"); + util_mkdirs(normal_dir, 0755); + util_mkdirs(sub_dir, 0755); + char *normal_events = util_path_join(normal_dir, "events.jsonl"); + char *sub_events = util_path_join(sub_dir, "events.jsonl"); + util_write_file(normal_events, "{}\n"); + util_write_file(sub_events, "{}\n"); + time_t now = time(NULL); + set_mtime(normal_events, now - 60); + set_mtime(sub_events, now); + + char *resolved = store_session_resolve_continue(home, cwd); + check(resolved && strcmp(resolved, "normal-session") == 0, + "continue skips a newer sub session"); + + free(resolved); + free(sub_events); + free(normal_events); + free(sub_dir); + free(normal_dir); + free(project_dir); + free(key); + free(cwd); + free(home); +} + +static void test_continue_rejects_only_sub_sessions(void) { + char *home = make_temp_dir("continue-only-sub"); + char *cwd = util_path_join(home, "project"); + util_mkdirs(cwd, 0755); + char *key = store_session_project_key(cwd); + char *project_dir = NULL; + asprintf(&project_dir, "%s/.bash-agent/projects/%s", home, key); + char *sub_dir = util_path_join(project_dir, "sub_only"); + util_mkdirs(sub_dir, 0755); + + char *resolved = store_session_resolve_continue(home, cwd); + check(resolved == NULL, "continue finds no session when only sub sessions exist"); + + free(resolved); + free(sub_dir); + free(project_dir); + free(key); + free(cwd); + free(home); +} + +static void test_explicit_sub_session_paths_remain_available(void) { + char *home = make_temp_dir("explicit-sub"); + char *cwd = util_path_join(home, "project"); + util_mkdirs(cwd, 0755); + SessionPaths paths = store_session_paths_for(home, cwd, "sub_manual"); + const char *name = strrchr(paths.session_dir, '/'); + check(name && strcmp(name + 1, "sub_manual") == 0, + "explicit sub session IDs remain available"); + + store_session_paths_free(&paths); + free(cwd); + free(home); +} + +int main(void) { + test_continue_skips_sub_sessions(); + test_continue_rejects_only_sub_sessions(); + test_explicit_sub_session_paths_remain_available(); + return failures == 0 ? 0 : 1; +} diff --git a/go/agent_test.go b/go/agent_test.go index 8f3457e..7143bc0 100644 --- a/go/agent_test.go +++ b/go/agent_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // ─── 配置测试 ─── @@ -163,8 +164,8 @@ func TestToolClassifyBashRequiredModeCWD(t *testing.T) { // 可信内部目录 "cat > /tmp/test.go << EOF": "0004", "cat /tmp-other/file": "0400", - "cat /tmp/../etc/hosts": "4000", - "echo hi > /tmp/../etc/native-file-mode-test": "2000", + "cat /tmp/../etc/hosts": "4000", + "echo hi > /tmp/../etc/native-file-mode-test": "2000", "cat > SAMPLE_HOME/.bash-agent/projects/session/file": "0004", "cat SAMPLE_HOME/.bash-agent/projects-copy/file": "0400", // /dev/null @@ -310,6 +311,72 @@ func TestFileStoreInit(t *testing.T) { } } +func TestFileStoreResolveContinueSkipsSubSessions(t *testing.T) { + home := t.TempDir() + cwd := t.TempDir() + store := NewFileStore(home, cwd) + projectDir := store.GetDir() + + normalDir := filepath.Join(projectDir, "normal-session") + subDir := filepath.Join(projectDir, "sub_latest") + if err := os.MkdirAll(normalDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + now := time.Now() + normalEvents := filepath.Join(normalDir, "events.jsonl") + subEvents := filepath.Join(subDir, "events.jsonl") + if err := os.WriteFile(normalEvents, []byte("{}\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(subEvents, []byte("{}\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(normalEvents, now.Add(-time.Hour), now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(subEvents, now, now); err != nil { + t.Fatal(err) + } + + id, err := store.ResolveContinue() + if err != nil { + t.Fatalf("ResolveContinue: %v", err) + } + if id != "normal-session" { + t.Fatalf("ResolveContinue = %q, want normal-session", id) + } +} + +func TestFileStoreResolveContinueWithOnlySubSessionsCreatesNewID(t *testing.T) { + home := t.TempDir() + cwd := t.TempDir() + store := NewFileStore(home, cwd) + if err := os.MkdirAll(filepath.Join(store.GetDir(), "sub_only"), 0755); err != nil { + t.Fatal(err) + } + + id, err := store.ResolveContinue() + if err != nil { + t.Fatalf("ResolveContinue: %v", err) + } + if id == "" || strings.HasPrefix(id, "sub_") { + t.Fatalf("ResolveContinue = %q, want a new regular session ID", id) + } +} + +func TestFileStoreExplicitSubSessionRemainsUsable(t *testing.T) { + store := NewFileStore(t.TempDir(), t.TempDir()) + if err := store.Init("sub_manual"); err != nil { + t.Fatalf("Init explicit sub session: %v", err) + } + if store.SessionID() != "sub_manual" { + t.Fatalf("SessionID = %q, want sub_manual", store.SessionID()) + } +} + func TestFileStoreConversation(t *testing.T) { store := newTestStore(t) diff --git a/go/store.go b/go/store.go index f92f9a3..b28bfec 100644 --- a/go/store.go +++ b/go/store.go @@ -231,7 +231,7 @@ func (s *FileStore) GetLatestDir() (string, error) { var latest string var latestTs int64 for _, e := range entries { - if !e.IsDir() { + if !e.IsDir() || strings.HasPrefix(e.Name(), "sub_") { continue } /* 优先用 events.jsonl 的 mtime,fallback 到目录 mtime(对齐 bash/rust) */ diff --git a/rust/src/store.rs b/rust/src/store.rs index a32f969..00d8ac0 100644 --- a/rust/src/store.rs +++ b/rust/src/store.rs @@ -605,6 +605,9 @@ use std::path::{Path, PathBuf}; continue; } let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("sub_") { + continue; + } let mt = session_activity_mod_time(&entry.path())?; match &newest { Some((ts, _)) if *ts >= mt => {} @@ -625,3 +628,55 @@ use std::path::{Path, PathBuf}; } Ok(fs::metadata(session_dir)?.modified()?) } + +#[cfg(test)] +mod continue_tests { + use super::*; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + fn test_root(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("rustagent-{name}-{}-{nonce}", std::process::id())) + } + + #[test] + fn continue_skips_newer_sub_session() { + let home = test_root("continue-skip-sub"); + let cwd = home.join("project"); + fs::create_dir_all(&cwd).unwrap(); + let project_dir = home.join(".bash-agent/projects").join(project_key(&cwd)); + let normal = project_dir.join("normal-session"); + let sub = project_dir.join("sub_latest"); + fs::create_dir_all(&normal).unwrap(); + std::thread::sleep(Duration::from_millis(20)); + fs::create_dir_all(&sub).unwrap(); + + assert_eq!(continue_session(&home, &cwd).unwrap(), "normal-session"); + let _ = fs::remove_dir_all(home); + } + + #[test] + fn continue_rejects_projects_with_only_sub_sessions() { + let home = test_root("continue-only-sub"); + let cwd = home.join("project"); + fs::create_dir_all(&cwd).unwrap(); + let project_dir = home.join(".bash-agent/projects").join(project_key(&cwd)); + fs::create_dir_all(project_dir.join("sub_only")).unwrap(); + + assert!(continue_session(&home, &cwd).is_err()); + let _ = fs::remove_dir_all(home); + } + + #[test] + fn explicit_sub_session_paths_remain_available() { + let home = test_root("explicit-sub"); + let cwd = home.join("project"); + let paths = paths_for(&home, &cwd, "sub_manual"); + + assert_eq!(paths.session_dir.file_name().unwrap(), "sub_manual"); + let _ = fs::remove_dir_all(home); + } +} diff --git a/src/agent.sh b/src/agent.sh index 94b14c7..dbada6a 100755 --- a/src/agent.sh +++ b/src/agent.sh @@ -418,7 +418,7 @@ store_session_get_dir() { } store_session_get_latest_dir() { - local project_dir latest="" latest_ts=0 dir ts stat_cmd + local project_dir latest="" latest_ts=0 dir name ts stat_cmd project_dir="$(store_session_get_dir)" [[ -d "$project_dir" ]] || return 1 if stat -c "%Y" "$project_dir" &>/dev/null; then @@ -428,6 +428,8 @@ store_session_get_latest_dir() { fi for dir in "$project_dir"/*/; do [[ -d "$dir" ]] || continue + name="$(basename "${dir%/}")" + [[ "$name" == sub_* ]] && continue local target="$dir" [[ -f "$dir/events.jsonl" ]] && target="$dir/events.jsonl" ts=$("${stat_cmd[@]}" "$target" 2>/dev/null || echo 0) diff --git a/tests/test_continue_sessions.sh b/tests/test_continue_sessions.sh new file mode 100644 index 0000000..15ee461 --- /dev/null +++ b/tests/test_continue_sessions.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +AGENT="${AGENT:-$ROOT_DIR/src/agent.sh}" +case "$AGENT" in + /*) ;; + *) AGENT="$ROOT_DIR/${AGENT#./}" ;; +esac + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/bash-agent-continue-test.XXXXXX") +PORT_FILE="$TMP_ROOT/port" +cleanup() { + if [[ -n "${SERVER_PID:-}" ]]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT + +python3 - "$PORT_FILE" <<'PY' & +import http.server +import sys + +port_file = sys.argv[1] + +class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + self.rfile.read(length) + body = b'{"error":{"message":"test stop"}}' + self.send_response(422) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +with open(port_file, "w", encoding="utf-8") as f: + f.write(str(server.server_port)) +server.serve_forever() +PY +SERVER_PID=$! +for _ in {1..100}; do + [[ -s "$PORT_FILE" ]] && break + sleep 0.02 +done +[[ -s "$PORT_FILE" ]] || { echo "mock server failed to start" >&2; exit 1; } +PORT=$(cat "$PORT_FILE") + +project_key() { + local cwd + cwd=$(cd "$1" && pwd -P) + cwd="${cwd#/}" + cwd="${cwd//\//-}" + cwd=$(printf '%s' "$cwd" | awk '{ gsub(/[^A-Za-z0-9._-]/, "-"); gsub(/-+/, "-", $0); sub(/^-+/, "", $0); sub(/-+$/, "", $0); print }') + printf -- '-%s' "$cwd" +} + +run_agent() { + local home=$1 cwd=$2 + shift 2 + ( + cd "$cwd" + BASH_AGENT_HOME="$home" HOME="$home" "$AGENT" \ + -p claude --base-url "http://127.0.0.1:$PORT/v1" \ + -m test --api-key test "$@" >/dev/null 2>&1 || true + ) +} + +# 最新活动目录是 SubAgent 会话时,自动续聊必须选择普通会话。 +home="$TMP_ROOT/latest-sub-home" +cwd="$TMP_ROOT/latest-sub-project" +mkdir -p "$home" "$cwd" +project_dir="$home/.bash-agent/projects/$(project_key "$cwd")" +mkdir -p "$project_dir/normal-session" "$project_dir/sub_latest" +printf '{}\n' > "$project_dir/normal-session/events.jsonl" +printf '{}\n' > "$project_dir/sub_latest/events.jsonl" +touch -t 202401010101 "$project_dir/normal-session/events.jsonl" +touch -t 202501010101 "$project_dir/sub_latest/events.jsonl" +run_agent "$home" "$cwd" --continue "continue-selection-marker" +grep -q 'continue-selection-marker' "$project_dir/normal-session/conversation.jsonl" +[[ ! -f "$project_dir/sub_latest/conversation.jsonl" ]] + +# 只有 SubAgent 会话时,自动续聊必须创建新的普通会话。 +home="$TMP_ROOT/only-sub-home" +cwd="$TMP_ROOT/only-sub-project" +mkdir -p "$home" "$cwd" +project_dir="$home/.bash-agent/projects/$(project_key "$cwd")" +mkdir -p "$project_dir/sub_only" +printf '{}\n' > "$project_dir/sub_only/events.jsonl" +run_agent "$home" "$cwd" --continue "new-session-marker" +regular_session=$(find "$project_dir" -mindepth 1 -maxdepth 1 -type d ! -name 'sub_*' -print | head -1) +regular_count=$(find "$project_dir" -mindepth 1 -maxdepth 1 -type d ! -name 'sub_*' -print | wc -l | tr -d ' ') +[[ "$regular_count" -eq 1 ]] +grep -q 'new-session-marker' "$regular_session/conversation.jsonl" + +# 显式指定 SubAgent session_id 时仍允许进入。 +home="$TMP_ROOT/explicit-sub-home" +cwd="$TMP_ROOT/explicit-sub-project" +mkdir -p "$home" "$cwd" +project_dir="$home/.bash-agent/projects/$(project_key "$cwd")" +run_agent "$home" "$cwd" --session sub_manual "explicit-sub-marker" +grep -q 'explicit-sub-marker' "$project_dir/sub_manual/conversation.jsonl" + +printf 'PASS: %s continue session filtering\n' "$(basename "$AGENT")"