From 3366a8ef092f6c12aef8e8f396459194344a5b9e Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 14 Apr 2026 10:24:24 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(session):=20=E5=A2=9E=E5=8A=A0=20Todo?= =?UTF-8?q?=20=E7=8A=B6=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/session-persistence-design.md | 17 ++ docs/session-todo-design.md | 47 ++++++ internal/session/store.go | 14 ++ internal/session/store_test.go | 123 ++++++++++++++ internal/session/todo.go | 255 +++++++++++++++++++++++++++++ internal/session/todo_test.go | 134 +++++++++++++++ 6 files changed, 590 insertions(+) create mode 100644 docs/session-todo-design.md create mode 100644 internal/session/todo.go create mode 100644 internal/session/todo_test.go diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index 2420ab5d..34974f31 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -25,6 +25,7 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、 - `created_at`、`updated_at` - `workdir` - `task_state` +- `todos` - `messages` - `token_input_total` - `token_output_total` @@ -51,6 +52,22 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、 - `user_constraints` - `last_updated_at` +`todos` 鍥哄畾鍖呭惈浠ヤ笅瑕佺偣锛? +- `id` +- `content` +- `status` +- `dependencies` +- `created_at` +- `updated_at` +- 鍙€?`priority` + +鍏朵腑 `status` 褰撳墠鍥哄畾涓猴細 +- `pending` +- `in_progress` +- `completed` + +鍚屾椂锛屽綋 session JSON 缂哄け `todos` 瀛楁鏃讹紝`Load` 浼氭寜绌?Todo 鍒楄〃鍏煎鍔犺浇銆? + ## 读写行为 - `Save` 使用“临时文件 + 原子替换”写入完整会话 JSON diff --git a/docs/session-todo-design.md b/docs/session-todo-design.md new file mode 100644 index 00000000..50c6d9f6 --- /dev/null +++ b/docs/session-todo-design.md @@ -0,0 +1,47 @@ +# Session Todo 设计说明 + +本文档补充说明 `internal/session` 中 Todo 的数据模型、持久化语义与边界约束。 + +## 设计目标 + +- Todo 归属于 `Session`,不单独引入新的持久化子系统。 +- Todo 只表示结构化待办状态,不替代现有 `TaskState`。 +- Todo 的校验、规范化和基础增删改查统一收敛在 `internal/session`。 + +## 数据模型 + +`Session` 新增 `todos` 字段,对应 `[]TodoItem`。 + +单个 `TodoItem` 目前包含: + +- `id` +- `content` +- `status` +- `dependencies` +- `created_at` +- `updated_at` +- 可选 `priority` + +其中 `status` 固定为以下三个值: + +- `pending` +- `in_progress` +- `completed` + +## 持久化语义 + +- Todo 跟随 `Session` 一起通过现有 JSONStore 保存和加载。 +- `Save` 前会对 Todo 执行统一规范化与校验: + - `id`、`content` 去空白 + - 空状态默认收敛为 `pending` + - `dependencies` 去空白、去重、保持顺序 + - 拒绝重复 ID + - 拒绝自依赖 + - 拒绝引用不存在的依赖项 +- `Load` 允许 session JSON 缺失 `todos` 字段,并按空 Todo 列表处理。 + +## 与 TaskState 的关系 + +- `TaskState` 仍是 runtime/context 用于 compact 与续航的 durable summary。 +- `Todo` 是更细粒度的结构化状态,不直接注入 context,不写入消息历史。 +- 如果未来需要收敛两者关系,应通过单独演进,让 `TaskState` 从 `Todo` 派生摘要,而不是直接复用同一字段。 diff --git a/internal/session/store.go b/internal/session/store.go index a5018040..35a8cab4 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -31,6 +31,7 @@ type Session struct { UpdatedAt time.Time `json:"updated_at"` Workdir string `json:"workdir,omitempty"` TaskState TaskState `json:"task_state"` + Todos []TodoItem `json:"todos,omitempty"` Messages []providertypes.Message `json:"messages"` TokenInputTotal int `json:"token_input_total,omitempty"` TokenOutputTotal int `json:"token_output_total,omitempty"` @@ -82,6 +83,11 @@ func (s *JSONStore) Save(ctx context.Context, session *Session) error { } session.TaskState = normalizeAndClampTaskState(session.TaskState) + normalizedTodos, err := normalizeAndValidateTodos(session.Todos) + if err != nil { + return err + } + session.Todos = normalizedTodos s.mu.Lock() defer s.mu.Unlock() @@ -205,6 +211,7 @@ func NewWithWorkdir(title string, workdir string) Session { UpdatedAt: now, Workdir: strings.TrimSpace(workdir), TaskState: TaskState{}, + Todos: []TodoItem{}, Messages: []providertypes.Message{}, } } @@ -246,6 +253,7 @@ func decodeStoredSession(data []byte) (Session, error) { UpdatedAt time.Time `json:"updated_at"` Workdir string `json:"workdir,omitempty"` TaskState *TaskState `json:"task_state"` + Todos []TodoItem `json:"todos,omitempty"` Messages []providertypes.Message `json:"messages"` TokenInput int `json:"token_input_total,omitempty"` TokenOutput int `json:"token_output_total,omitempty"` @@ -273,6 +281,7 @@ func decodeStoredSession(data []byte) (Session, error) { UpdatedAt: stored.UpdatedAt, Workdir: stored.Workdir, TaskState: *stored.TaskState, + Todos: stored.Todos, Messages: stored.Messages, TokenInputTotal: stored.TokenInput, TokenOutputTotal: stored.TokenOutput, @@ -281,6 +290,11 @@ func decodeStoredSession(data []byte) (Session, error) { return Session{}, err } session.TaskState = normalizeAndClampTaskState(session.TaskState) + normalizedTodos, err := normalizeAndValidateTodos(session.Todos) + if err != nil { + return Session{}, err + } + session.Todos = normalizedTodos return session, nil } diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 5a0dc1e6..35b57d45 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -769,6 +769,129 @@ func TestJSONStoreLoadClampsOversizedTaskState(t *testing.T) { } } +func TestJSONStoreSaveLoadRoundTripTodos(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + createdAt := time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC) + updatedAt := createdAt.Add(5 * time.Minute) + session := &Session{ + SchemaVersion: CurrentSchemaVersion, + ID: "todos-round-trip", + Title: "Todos Round Trip", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + TaskState: TaskState{}, + Todos: []TodoItem{ + { + ID: "todo-1", + Content: " design session todo model ", + Status: TodoStatusPending, + Dependencies: []string{"todo-2", "todo-2", " "}, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + { + ID: "todo-2", + Content: "persist todos in session", + Status: TodoStatusInProgress, + Priority: 2, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + }, + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + } + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save session with todos: %v", err) + } + if got := session.Todos[0].Dependencies; len(got) != 1 || got[0] != "todo-2" { + t.Fatalf("expected dependencies normalized in-memory, got %+v", got) + } + if got := session.Todos[0].Content; got != "design session todo model" { + t.Fatalf("expected content normalized, got %q", got) + } + + loaded, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load session with todos: %v", err) + } + if len(loaded.Todos) != 2 { + t.Fatalf("expected 2 todos, got %d", len(loaded.Todos)) + } + if loaded.Todos[0].ID != "todo-1" || loaded.Todos[1].ID != "todo-2" { + t.Fatalf("unexpected todo ids: %+v", loaded.Todos) + } + if loaded.Todos[1].Priority != 2 { + t.Fatalf("expected priority 2, got %d", loaded.Todos[1].Priority) + } +} + +func TestJSONStoreLoadAllowsMissingTodosField(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "no-todos.json"), strings.Join([]string{ + `{`, + ` "schema_version": 1,`, + ` "id": "no-todos",`, + ` "title": "No Todos",`, + ` "created_at": "2026-04-14T10:00:00Z",`, + ` "updated_at": "2026-04-14T10:05:00Z",`, + ` "task_state": {`, + ` "goal": "",`, + ` "progress": [],`, + ` "open_items": [],`, + ` "next_step": "",`, + ` "blockers": [],`, + ` "key_artifacts": [],`, + ` "decisions": [],`, + ` "user_constraints": [],`, + ` "last_updated_at": "2026-04-14T10:05:00Z"`, + ` },`, + ` "messages": []`, + `}`, + }, "\n")) + + loaded, err := store.Load(context.Background(), "no-todos") + if err != nil { + t.Fatalf("load session without todos field: %v", err) + } + if len(loaded.Todos) != 0 { + t.Fatalf("expected no todos, got %+v", loaded.Todos) + } +} + +func TestJSONStoreSaveRejectsInvalidTodos(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + err := store.Save(context.Background(), &Session{ + SchemaVersion: CurrentSchemaVersion, + ID: "invalid-todos", + Title: "Invalid Todos", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now(), + TaskState: TaskState{}, + Todos: []TodoItem{ + {ID: "todo-1", Content: "first", Dependencies: []string{"missing"}}, + }, + }) + if err == nil || !strings.Contains(err.Error(), `unknown dependency "missing"`) { + t.Fatalf("expected invalid dependency error, got %v", err) + } +} + func buildQuotedRepeatedWithIndex(ch string, itemLen int, count int) string { items := make([]string, 0, count) for i := 0; i < count; i++ { diff --git a/internal/session/todo.go b/internal/session/todo.go new file mode 100644 index 00000000..61a80230 --- /dev/null +++ b/internal/session/todo.go @@ -0,0 +1,255 @@ +package session + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// TodoStatus 表示 Todo 项的稳定状态枚举。 +type TodoStatus string + +const ( + // TodoStatusPending 表示尚未开始的待办项。 + TodoStatusPending TodoStatus = "pending" + // TodoStatusInProgress 表示正在执行中的待办项。 + TodoStatusInProgress TodoStatus = "in_progress" + // TodoStatusCompleted 表示已经完成的待办项。 + TodoStatusCompleted TodoStatus = "completed" +) + +// TodoItem 表示会话级结构化待办项。 +type TodoItem struct { + ID string `json:"id"` + Content string `json:"content"` + Status TodoStatus `json:"status"` + Dependencies []string `json:"dependencies,omitempty"` + Priority int `json:"priority,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Clone 返回 Todo 项的深拷贝,避免依赖切片共享底层存储。 +func (i TodoItem) Clone() TodoItem { + i.Dependencies = append([]string(nil), i.Dependencies...) + return i +} + +// Valid 判断当前状态值是否属于受支持的 Todo 状态。 +func (s TodoStatus) Valid() bool { + switch s { + case TodoStatusPending, TodoStatusInProgress, TodoStatusCompleted: + return true + default: + return false + } +} + +// FindTodo 按 ID 查找 Todo 项并返回深拷贝结果。 +func (s Session) FindTodo(id string) (TodoItem, bool) { + id = strings.TrimSpace(id) + if id == "" { + return TodoItem{}, false + } + + for _, item := range s.Todos { + if item.ID == id { + return item.Clone(), true + } + } + return TodoItem{}, false +} + +// AddTodo 向会话追加一个新的 Todo 项,并补齐默认状态与时间戳。 +func (s *Session) AddTodo(item TodoItem) error { + if s == nil { + return errors.New("session: session is nil") + } + + now := time.Now() + if item.CreatedAt.IsZero() { + item.CreatedAt = now + } + if item.UpdatedAt.IsZero() { + item.UpdatedAt = item.CreatedAt + } + + normalized, err := normalizeAndValidateTodos(append(append([]TodoItem(nil), s.Todos...), item)) + if err != nil { + return err + } + + s.Todos = normalized + return nil +} + +// UpdateTodoStatus 按 ID 更新 Todo 状态,并刷新该项的更新时间。 +func (s *Session) UpdateTodoStatus(id string, status TodoStatus) error { + if s == nil { + return errors.New("session: session is nil") + } + + id = strings.TrimSpace(id) + if id == "" { + return errors.New("session: todo id is empty") + } + if !status.Valid() { + return fmt.Errorf("session: invalid todo status %q", status) + } + + items := append([]TodoItem(nil), s.Todos...) + for i := range items { + if items[i].ID != id { + continue + } + items[i].Status = status + items[i].UpdatedAt = time.Now() + + normalized, err := normalizeAndValidateTodos(items) + if err != nil { + return err + } + s.Todos = normalized + return nil + } + + return fmt.Errorf("session: todo %q not found", id) +} + +// DeleteTodo 按 ID 删除 Todo 项,并在删除前显式阻止仍被其他 Todo 依赖的记录。 +func (s *Session) DeleteTodo(id string) error { + if s == nil { + return errors.New("session: session is nil") + } + + id = strings.TrimSpace(id) + if id == "" { + return errors.New("session: todo id is empty") + } + if dependents := findTodoDependents(s.Todos, id); len(dependents) > 0 { + return fmt.Errorf("session: todo %q is still required by %s", id, strings.Join(dependents, ", ")) + } + + items := make([]TodoItem, 0, len(s.Todos)) + found := false + for _, item := range s.Todos { + if item.ID == id { + found = true + continue + } + items = append(items, item) + } + if !found { + return fmt.Errorf("session: todo %q not found", id) + } + + normalized, err := normalizeAndValidateTodos(items) + if err != nil { + return err + } + s.Todos = normalized + return nil +} + +// findTodoDependents 返回依赖指定 Todo ID 的所有待办项 ID,并保持原有顺序。 +func findTodoDependents(items []TodoItem, id string) []string { + if len(items) == 0 || strings.TrimSpace(id) == "" { + return nil + } + + dependents := make([]string, 0) + for _, item := range items { + for _, dependency := range item.Dependencies { + if dependency == id { + dependents = append(dependents, item.ID) + break + } + } + } + if len(dependents) == 0 { + return nil + } + return dependents +} + +// normalizeAndValidateTodos 统一收敛 Todo 的文本、状态与依赖合法性。 +func normalizeAndValidateTodos(items []TodoItem) ([]TodoItem, error) { + if len(items) == 0 { + return nil, nil + } + + normalized := make([]TodoItem, 0, len(items)) + ids := make(map[string]struct{}, len(items)) + for _, item := range items { + next, err := normalizeTodoItem(item) + if err != nil { + return nil, err + } + if _, exists := ids[next.ID]; exists { + return nil, fmt.Errorf("session: duplicate todo id %q", next.ID) + } + ids[next.ID] = struct{}{} + normalized = append(normalized, next) + } + + for _, item := range normalized { + for _, dependency := range item.Dependencies { + if dependency == item.ID { + return nil, fmt.Errorf("session: todo %q cannot depend on itself", item.ID) + } + if _, exists := ids[dependency]; !exists { + return nil, fmt.Errorf("session: todo %q references unknown dependency %q", item.ID, dependency) + } + } + } + + return normalized, nil +} + +// normalizeTodoItem 规范化单个 Todo 项,确保持久化结构稳定。 +func normalizeTodoItem(item TodoItem) (TodoItem, error) { + item = item.Clone() + item.ID = strings.TrimSpace(item.ID) + item.Content = strings.TrimSpace(item.Content) + item.Dependencies = normalizeTodoDependencies(item.Dependencies) + if item.Status == "" { + item.Status = TodoStatusPending + } + + switch { + case item.ID == "": + return TodoItem{}, errors.New("session: todo id is empty") + case item.Content == "": + return TodoItem{}, fmt.Errorf("session: todo %q content is empty", item.ID) + case !item.Status.Valid(): + return TodoItem{}, fmt.Errorf("session: invalid todo status %q", item.Status) + } + + return item, nil +} + +// normalizeTodoDependencies 对依赖列表做去空白、去重并保持顺序。 +func normalizeTodoDependencies(dependencies []string) []string { + if len(dependencies) == 0 { + return nil + } + + result := make([]string, 0, len(dependencies)) + seen := make(map[string]struct{}, len(dependencies)) + for _, dependency := range dependencies { + dependency = strings.TrimSpace(dependency) + if dependency == "" { + continue + } + if _, exists := seen[dependency]; exists { + continue + } + seen[dependency] = struct{}{} + result = append(result, dependency) + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/internal/session/todo_test.go b/internal/session/todo_test.go new file mode 100644 index 00000000..8298819a --- /dev/null +++ b/internal/session/todo_test.go @@ -0,0 +1,134 @@ +package session + +import ( + "strings" + "testing" + "time" +) + +func TestSessionAddTodoFindTodoAndDeleteTodo(t *testing.T) { + t.Parallel() + + session := New("Todo Helpers") + if err := session.AddTodo(TodoItem{ + ID: "todo-1", + Content: " implement todos ", + Dependencies: []string{"todo-2", "todo-2", " "}, + }); err == nil || !strings.Contains(err.Error(), `unknown dependency "todo-2"`) { + t.Fatalf("expected dependency validation error, got %v", err) + } + + if err := session.AddTodo(TodoItem{ + ID: "todo-2", + Content: "write tests", + Status: TodoStatusCompleted, + }); err != nil { + t.Fatalf("add todo-2: %v", err) + } + if err := session.AddTodo(TodoItem{ + ID: "todo-1", + Content: " implement todos ", + Dependencies: []string{"todo-2", "todo-2", " "}, + }); err != nil { + t.Fatalf("add todo-1: %v", err) + } + + found, ok := session.FindTodo("todo-1") + if !ok { + t.Fatalf("expected to find todo-1") + } + if found.Content != "implement todos" { + t.Fatalf("expected normalized content, got %q", found.Content) + } + if len(found.Dependencies) != 1 || found.Dependencies[0] != "todo-2" { + t.Fatalf("expected normalized dependencies, got %+v", found.Dependencies) + } + + if err := session.DeleteTodo("todo-2"); err == nil || !strings.Contains(err.Error(), `still required by todo-1`) { + t.Fatalf("expected delete of depended-on todo to fail with dependent info, got %v", err) + } + if err := session.DeleteTodo("todo-1"); err != nil { + t.Fatalf("delete todo-1: %v", err) + } + if err := session.DeleteTodo("todo-2"); err != nil { + t.Fatalf("delete todo-2: %v", err) + } + if len(session.Todos) != 0 { + t.Fatalf("expected empty todos after deletions, got %+v", session.Todos) + } +} + +func TestSessionUpdateTodoStatus(t *testing.T) { + t.Parallel() + + session := New("Update Todo Status") + createdAt := time.Now().Add(-time.Minute).UTC().Truncate(time.Second) + if err := session.AddTodo(TodoItem{ + ID: "todo-1", + Content: "implement status update", + Status: TodoStatusPending, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }); err != nil { + t.Fatalf("add todo: %v", err) + } + + before := session.Todos[0].UpdatedAt + if err := session.UpdateTodoStatus("todo-1", TodoStatusInProgress); err != nil { + t.Fatalf("update todo status: %v", err) + } + + got, ok := session.FindTodo("todo-1") + if !ok { + t.Fatalf("expected to find updated todo") + } + if got.Status != TodoStatusInProgress { + t.Fatalf("expected status %q, got %q", TodoStatusInProgress, got.Status) + } + if !got.UpdatedAt.After(before) { + t.Fatalf("expected updated_at to advance, got before=%v after=%v", before, got.UpdatedAt) + } +} + +func TestSessionAddTodoRejectsDuplicateID(t *testing.T) { + t.Parallel() + + session := New("Duplicate Todo") + if err := session.AddTodo(TodoItem{ID: "todo-1", Content: "first"}); err != nil { + t.Fatalf("add first todo: %v", err) + } + err := session.AddTodo(TodoItem{ID: "todo-1", Content: "second"}) + if err == nil || !strings.Contains(err.Error(), `duplicate todo id "todo-1"`) { + t.Fatalf("expected duplicate id error, got %v", err) + } +} + +func TestNormalizeAndValidateTodosRejectsSelfDependencyAndInvalidStatus(t *testing.T) { + t.Parallel() + + if _, err := normalizeAndValidateTodos([]TodoItem{ + {ID: "todo-1", Content: "self", Dependencies: []string{"todo-1"}}, + }); err == nil || !strings.Contains(err.Error(), `cannot depend on itself`) { + t.Fatalf("expected self dependency error, got %v", err) + } + + if _, err := normalizeAndValidateTodos([]TodoItem{ + {ID: "todo-1", Content: "bad status", Status: TodoStatus("paused")}, + }); err == nil || !strings.Contains(err.Error(), `invalid todo status`) { + t.Fatalf("expected invalid status error, got %v", err) + } +} + +func TestFindTodoDependentsPreservesOrder(t *testing.T) { + t.Parallel() + + got := findTodoDependents([]TodoItem{ + {ID: "todo-1", Dependencies: []string{"todo-9"}}, + {ID: "todo-2", Dependencies: []string{"other", "todo-9"}}, + {ID: "todo-3", Dependencies: []string{"other"}}, + }, "todo-9") + + if len(got) != 2 || got[0] != "todo-1" || got[1] != "todo-2" { + t.Fatalf("expected ordered dependents [todo-1 todo-2], got %+v", got) + } +} From c190263512af7f60fad7f4e6d433886c5c058a4e Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 14 Apr 2026 11:34:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(session):=20=E8=A1=A5=E5=85=85=20Todo?= =?UTF-8?q?=20=E5=BC=82=E5=B8=B8=E8=B7=AF=E5=BE=84=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/session/store_test.go | 44 ++++++++++++++++++++++++++++++++++ internal/session/todo_test.go | 38 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 35b57d45..e08710b1 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -892,6 +892,50 @@ func TestJSONStoreSaveRejectsInvalidTodos(t *testing.T) { } } +func TestJSONStoreLoadRejectsInvalidTodos(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "invalid-todos-load.json"), strings.Join([]string{ + `{`, + ` "schema_version": 1,`, + ` "id": "invalid-todos-load",`, + ` "title": "Invalid Todos Load",`, + ` "created_at": "2026-04-14T10:00:00Z",`, + ` "updated_at": "2026-04-14T10:05:00Z",`, + ` "task_state": {`, + ` "goal": "",`, + ` "progress": [],`, + ` "open_items": [],`, + ` "next_step": "",`, + ` "blockers": [],`, + ` "key_artifacts": [],`, + ` "decisions": [],`, + ` "user_constraints": [],`, + ` "last_updated_at": "2026-04-14T10:05:00Z"`, + ` },`, + ` "todos": [`, + ` {`, + ` "id": "todo-1",`, + ` "content": "broken todo",`, + ` "status": "paused",`, + ` "created_at": "2026-04-14T10:00:00Z",`, + ` "updated_at": "2026-04-14T10:05:00Z"`, + ` }`, + ` ],`, + ` "messages": []`, + `}`, + }, "\n")) + + _, err := store.Load(context.Background(), "invalid-todos-load") + if err == nil || !strings.Contains(err.Error(), `invalid todo status "paused"`) { + t.Fatalf("expected invalid todo status load error, got %v", err) + } +} + func buildQuotedRepeatedWithIndex(ch string, itemLen int, count int) string { items := make([]string, 0, count) for i := 0; i < count; i++ { diff --git a/internal/session/todo_test.go b/internal/session/todo_test.go index 8298819a..8a7b4d03 100644 --- a/internal/session/todo_test.go +++ b/internal/session/todo_test.go @@ -90,6 +90,22 @@ func TestSessionUpdateTodoStatus(t *testing.T) { } } +func TestSessionUpdateTodoStatusRejectsUnknownTodoAndInvalidStatus(t *testing.T) { + t.Parallel() + + session := New("Update Todo Status Errors") + if err := session.AddTodo(TodoItem{ID: "todo-1", Content: "existing"}); err != nil { + t.Fatalf("add todo: %v", err) + } + + if err := session.UpdateTodoStatus("missing", TodoStatusCompleted); err == nil || !strings.Contains(err.Error(), `todo "missing" not found`) { + t.Fatalf("expected unknown todo error, got %v", err) + } + if err := session.UpdateTodoStatus("todo-1", TodoStatus("paused")); err == nil || !strings.Contains(err.Error(), `invalid todo status`) { + t.Fatalf("expected invalid status error, got %v", err) + } +} + func TestSessionAddTodoRejectsDuplicateID(t *testing.T) { t.Parallel() @@ -132,3 +148,25 @@ func TestFindTodoDependentsPreservesOrder(t *testing.T) { t.Fatalf("expected ordered dependents [todo-1 todo-2], got %+v", got) } } + +func TestSessionDeleteTodoReportsAllDependentsAndNotFound(t *testing.T) { + t.Parallel() + + session := New("Delete Todo Errors") + for _, item := range []TodoItem{ + {ID: "todo-9", Content: "shared dependency"}, + {ID: "todo-1", Content: "first dependent", Dependencies: []string{"todo-9"}}, + {ID: "todo-2", Content: "second dependent", Dependencies: []string{"todo-9"}}, + } { + if err := session.AddTodo(item); err != nil { + t.Fatalf("add todo %q: %v", item.ID, err) + } + } + + if err := session.DeleteTodo("todo-9"); err == nil || !strings.Contains(err.Error(), `still required by todo-1, todo-2`) { + t.Fatalf("expected dependent list error, got %v", err) + } + if err := session.DeleteTodo("missing"); err == nil || !strings.Contains(err.Error(), `todo "missing" not found`) { + t.Fatalf("expected not found error, got %v", err) + } +} From c3af8c451c1b1928f9750361ce80bb680762870f Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 03:40:54 +0000 Subject: [PATCH 3/3] fix(session): resolve todo doc garbling and simplify todo id validation - repair garbled UTF-8 text in session persistence todo section - extract ensureTodoID to reduce duplicated id normalization - add regression test for empty todo id behavior Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- docs/session-persistence-design.md | 8 ++++---- internal/session/todo.go | 27 +++++++++++++++++++-------- internal/session/todo_test.go | 15 +++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index 34974f31..03ce5aa9 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -52,21 +52,21 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、 - `user_constraints` - `last_updated_at` -`todos` 鍥哄畾鍖呭惈浠ヤ笅瑕佺偣锛? +`todos` 固定包含以下要点: - `id` - `content` - `status` - `dependencies` - `created_at` - `updated_at` -- 鍙€?`priority` +- 可选 `priority` -鍏朵腑 `status` 褰撳墠鍥哄畾涓猴細 +其中 `status` 当前固定为: - `pending` - `in_progress` - `completed` -鍚屾椂锛屽綋 session JSON 缂哄け `todos` 瀛楁鏃讹紝`Load` 浼氭寜绌?Todo 鍒楄〃鍏煎鍔犺浇銆? +同时,当 session JSON 缺失 `todos` 字段时,`Load` 会按空 Todo 列表兼容加载。 ## 读写行为 diff --git a/internal/session/todo.go b/internal/session/todo.go index 61a80230..f805aecd 100644 --- a/internal/session/todo.go +++ b/internal/session/todo.go @@ -48,8 +48,8 @@ func (s TodoStatus) Valid() bool { // FindTodo 按 ID 查找 Todo 项并返回深拷贝结果。 func (s Session) FindTodo(id string) (TodoItem, bool) { - id = strings.TrimSpace(id) - if id == "" { + id, err := ensureTodoID(id) + if err != nil { return TodoItem{}, false } @@ -90,9 +90,10 @@ func (s *Session) UpdateTodoStatus(id string, status TodoStatus) error { return errors.New("session: session is nil") } - id = strings.TrimSpace(id) - if id == "" { - return errors.New("session: todo id is empty") + var err error + id, err = ensureTodoID(id) + if err != nil { + return err } if !status.Valid() { return fmt.Errorf("session: invalid todo status %q", status) @@ -123,9 +124,10 @@ func (s *Session) DeleteTodo(id string) error { return errors.New("session: session is nil") } - id = strings.TrimSpace(id) - if id == "" { - return errors.New("session: todo id is empty") + var err error + id, err = ensureTodoID(id) + if err != nil { + return err } if dependents := findTodoDependents(s.Todos, id); len(dependents) > 0 { return fmt.Errorf("session: todo %q is still required by %s", id, strings.Join(dependents, ", ")) @@ -253,3 +255,12 @@ func normalizeTodoDependencies(dependencies []string) []string { } return result } + +// ensureTodoID 统一校验并返回规范化后的 Todo ID。 +func ensureTodoID(id string) (string, error) { + id = strings.TrimSpace(id) + if id == "" { + return "", errors.New("session: todo id is empty") + } + return id, nil +} diff --git a/internal/session/todo_test.go b/internal/session/todo_test.go index 8a7b4d03..83ca93c4 100644 --- a/internal/session/todo_test.go +++ b/internal/session/todo_test.go @@ -106,6 +106,21 @@ func TestSessionUpdateTodoStatusRejectsUnknownTodoAndInvalidStatus(t *testing.T) } } +func TestSessionTodoHelpersRejectEmptyID(t *testing.T) { + t.Parallel() + + session := New("Todo Empty ID") + if _, ok := session.FindTodo(" "); ok { + t.Fatalf("expected FindTodo to reject empty id") + } + if err := session.UpdateTodoStatus(" ", TodoStatusCompleted); err == nil || !strings.Contains(err.Error(), "todo id is empty") { + t.Fatalf("expected update empty id error, got %v", err) + } + if err := session.DeleteTodo("\n\t "); err == nil || !strings.Contains(err.Error(), "todo id is empty") { + t.Fatalf("expected delete empty id error, got %v", err) + } +} + func TestSessionAddTodoRejectsDuplicateID(t *testing.T) { t.Parallel()