Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions c/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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

Expand Down
2 changes: 1 addition & 1 deletion c/store.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
115 changes: 115 additions & 0 deletions c/test_continue.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#include "store.h"
#include "util.h"

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>

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;
}
71 changes: 69 additions & 2 deletions go/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)

// ─── 配置测试 ───
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion go/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
55 changes: 55 additions & 0 deletions rust/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {}
Expand All @@ -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);
}
}
4 changes: 3 additions & 1 deletion src/agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading