detached 83c2c60

Merge pull request #46 from bnema/fix/session-tree-family

fix(session): support extension state in tree history
commit
83c2c60872e5058464f09898bb7e4e7c31df0098
author
bnema · · 2026-08-06 13:37 UTC
committer
GitHub · · 2026-08-06 13:37 UTC
merge of
d107bf9 + d228a57 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.4.2
5 files · +68 -1
M packages/agent/modes/session_tree_dialog_test.go +28 -0
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "encoding/json"
45 "os"
56 "strings"
67 "testing"
@@ -121,6 +122,33 @@ func TestSessionTreeRetainsPreCompactionMessagesAndHidesToolRows(t *testing.T) {
121122 }
122123 }
123124
125+func TestSessionTreeOpensWithExtensionStateRows(t *testing.T) {
126+ root := t.TempDir()
127+ cwd := "/workspace/tree-extension-state"
128+ session, err := core.NewSession(root, cwd, "test", "model", "test")
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := session.AppendMessage(treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "prompt"})); err != nil {
133+ t.Fatal(err)
134+ }
135+ if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := session.AppendMessage(treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"})); err != nil {
139+ t.Fatal(err)
140+ }
141+ path := session.Path
142+ if err := session.Close(); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ d := newSessionTreeDialog()
147+ if !d.OpenSessionFamily(root, cwd, path) {
148+ t.Fatal("OpenSessionFamily returned false for a session with extension state")
149+ }
150+}
151+
124152 func TestSessionTreeHistoricalSelectionCreatesBranchFromOldSegment(t *testing.T) {
125153 root := t.TempDir()
126154 cwd := "/workspace/tree-history-branch"
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "encoding/json"
4 "os"5 "os"
5 "strings"6 "strings"
6 "testing"7 "testing"
@@ -121,6 +122,33 @@ func TestSessionTreeRetainsPreCompactionMessagesAndHidesToolRows(t *testing.T) {
121 }122 }
122 }123 }
123 124
125+func TestSessionTreeOpensWithExtensionStateRows(t *testing.T) {
126+ root := t.TempDir()
127+ cwd := "/workspace/tree-extension-state"
128+ session, err := core.NewSession(root, cwd, "test", "model", "test")
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := session.AppendMessage(treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "prompt"})); err != nil {
133+ t.Fatal(err)
134+ }
135+ if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := session.AppendMessage(treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "answer"})); err != nil {
139+ t.Fatal(err)
140+ }
141+ path := session.Path
142+ if err := session.Close(); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ d := newSessionTreeDialog()
147+ if !d.OpenSessionFamily(root, cwd, path) {
148+ t.Fatal("OpenSessionFamily returned false for a session with extension state")
149+ }
150+}
151+
124 func TestSessionTreeHistoricalSelectionCreatesBranchFromOldSegment(t *testing.T) {152 func TestSessionTreeHistoricalSelectionCreatesBranchFromOldSegment(t *testing.T) {
125 root := t.TempDir()153 root := t.TempDir()
126 cwd := "/workspace/tree-history-branch"154 cwd := "/workspace/tree-history-branch"
M packages/core/session.go +5 -0
@@ -535,6 +535,11 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
535535 cumulative: *row.Cumulative,
536536 })
537537
538+ case "extension_state":
539+ // Extension state is session metadata rather than transcript history.
540+ // The snapshot reader restores it for resume; tree history only needs
541+ // provider messages and usage checkpoints.
542+
538543 case "rename":
539544 var row struct {
540545 Title *string `json:"title"`
@@ -535,6 +535,11 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
535 cumulative: *row.Cumulative,535 cumulative: *row.Cumulative,
536 })536 })
537 537
538+ case "extension_state":
539+ // Extension state is session metadata rather than transcript history.
540+ // The snapshot reader restores it for resume; tree history only needs
541+ // provider messages and usage checkpoints.
542+
538 case "rename":543 case "rename":
539 var row struct {544 var row struct {
540 Title *string `json:"title"`545 Title *string `json:"title"`
M packages/core/session_history_test.go +31 -0
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "encoding/json"
45 "testing"
56 "time"
67
@@ -84,6 +85,36 @@ func TestReadSessionHistoryRetainsPreCompactionSegments(t *testing.T) {
8485 }
8586 }
8687
88+func TestReadSessionHistoryIgnoresExtensionStateRows(t *testing.T) {
89+ root := t.TempDir()
90+ session, err := NewSession(root, "/workspace/history-extension-state", "test", "model", "test")
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if err := session.AppendMessage(textMessage(provider.RoleUser, "prompt")); err != nil {
95+ t.Fatal(err)
96+ }
97+ if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
98+ t.Fatal(err)
99+ }
100+ if err := session.AppendMessage(textMessage(provider.RoleAssistant, "answer")); err != nil {
101+ t.Fatal(err)
102+ }
103+ path := session.Path
104+ if err := session.Close(); err != nil {
105+ t.Fatal(err)
106+ }
107+
108+ history, err := ReadSessionHistory(path)
109+ if err != nil {
110+ t.Fatalf("ReadSessionHistory: %v", err)
111+ }
112+ if len(history.Segments) != 1 {
113+ t.Fatalf("history segments = %d, want 1", len(history.Segments))
114+ }
115+ assertMessageTexts(t, history.Segments[0].Messages, []string{"prompt", "answer"})
116+}
117+
87118 func TestReadSessionHistoryRepairsEachSegmentIndependently(t *testing.T) {
88119 root := t.TempDir()
89120 session, err := NewSession(root, "/workspace/history-repair", "test", "model", "test")
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "encoding/json"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -84,6 +85,36 @@ func TestReadSessionHistoryRetainsPreCompactionSegments(t *testing.T) {
84 }85 }
85 }86 }
86 87
88+func TestReadSessionHistoryIgnoresExtensionStateRows(t *testing.T) {
89+ root := t.TempDir()
90+ session, err := NewSession(root, "/workspace/history-extension-state", "test", "model", "test")
91+ if err != nil {
92+ t.Fatal(err)
93+ }
94+ if err := session.AppendMessage(textMessage(provider.RoleUser, "prompt")); err != nil {
95+ t.Fatal(err)
96+ }
97+ if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
98+ t.Fatal(err)
99+ }
100+ if err := session.AppendMessage(textMessage(provider.RoleAssistant, "answer")); err != nil {
101+ t.Fatal(err)
102+ }
103+ path := session.Path
104+ if err := session.Close(); err != nil {
105+ t.Fatal(err)
106+ }
107+
108+ history, err := ReadSessionHistory(path)
109+ if err != nil {
110+ t.Fatalf("ReadSessionHistory: %v", err)
111+ }
112+ if len(history.Segments) != 1 {
113+ t.Fatalf("history segments = %d, want 1", len(history.Segments))
114+ }
115+ assertMessageTexts(t, history.Segments[0].Messages, []string{"prompt", "answer"})
116+}
117+
87 func TestReadSessionHistoryRepairsEachSegmentIndependently(t *testing.T) {118 func TestReadSessionHistoryRepairsEachSegmentIndependently(t *testing.T) {
88 root := t.TempDir()119 root := t.TempDir()
89 session, err := NewSession(root, "/workspace/history-repair", "test", "model", "test")120 session, err := NewSession(root, "/workspace/history-repair", "test", "model", "test")
M packages/core/session_portable.go +1 -1
@@ -824,7 +824,7 @@ func scanSessionMeta(path string) (meta SessionMeta, err error) {
824824 }
825825 meta = *row.Meta
826826 sawMeta = true
827- case "message", "compaction", "usage", "rename":
827+ case "message", "compaction", "usage", "rename", "extension_state":
828828 // These rows are intentionally not hydrated here.
829829 default:
830830 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
@@ -824,7 +824,7 @@ func scanSessionMeta(path string) (meta SessionMeta, err error) {
824 }824 }
825 meta = *row.Meta825 meta = *row.Meta
826 sawMeta = true826 sawMeta = true
827- case "message", "compaction", "usage", "rename":827+ case "message", "compaction", "usage", "rename", "extension_state":
828 // These rows are intentionally not hydrated here.828 // These rows are intentionally not hydrated here.
829 default:829 default:
830 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)830 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
M packages/core/session_portable_test.go +3 -0
@@ -144,6 +144,9 @@ func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) {
144144 if err := session.UpdateModel("new-provider", "new-model"); err != nil {
145145 t.Fatal(err)
146146 }
147+ if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
148+ t.Fatal(err)
149+ }
147150 path := session.Path
148151 if err := session.Close(); err != nil {
149152 t.Fatal(err)
@@ -144,6 +144,9 @@ func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) {
144 if err := session.UpdateModel("new-provider", "new-model"); err != nil {144 if err := session.UpdateModel("new-provider", "new-model"); err != nil {
145 t.Fatal(err)145 t.Fatal(err)
146 }146 }
147+ if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
148+ t.Fatal(err)
149+ }
147 path := session.Path150 path := session.Path
148 if err := session.Close(); err != nil {151 if err := session.Close(); err != nil {
149 t.Fatal(err)152 t.Fatal(err)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close