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
17 changes: 17 additions & 0 deletions docs/session-persistence-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、
- `created_at`、`updated_at`
- `workdir`
- `task_state`
- `todos`
- `messages`
- `token_input_total`
- `token_output_total`
Expand All @@ -51,6 +52,22 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、
- `user_constraints`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked this section in the latest commit: the UTF-8 text is readable and aligned with the current Todo persistence behavior.

- `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
Expand Down
47 changes: 47 additions & 0 deletions docs/session-todo-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Session Todo 设计说明

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed end-to-end against the current implementation in internal/session; no inconsistencies found here.


本文档补充说明 `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` 派生摘要,而不是直接复用同一字段。
14 changes: 14 additions & 0 deletions internal/session/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -205,6 +211,7 @@ func NewWithWorkdir(title string, workdir string) Session {
UpdatedAt: now,
Workdir: strings.TrimSpace(workdir),
TaskState: TaskState{},
Todos: []TodoItem{},
Messages: []providertypes.Message{},
}
}
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand All @@ -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
}

Expand Down
167 changes: 167 additions & 0 deletions internal/session/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,173 @@ 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 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++ {
Expand Down
Loading
Loading