-
Notifications
You must be signed in to change notification settings - Fork 2
fix: per-session message queue, conversation restore, token usage i18n + CI race #147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| package session | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "path/filepath" | ||
|
|
||
| "github.com/cnjack/jcode/internal/config" | ||
| ) | ||
|
|
||
| // lastSessionFile is the on-disk structure of last_session.json: the most | ||
| // recently foregrounded session per project, so a web/desktop client can | ||
| // return to the conversation that was open before a restart. | ||
| type lastSessionFile struct { | ||
| Projects map[string]string `json:"projects"` // project path → session uuid | ||
| } | ||
|
|
||
| func lastSessionPath() (string, error) { | ||
| dir, err := config.SessionsDir() | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return filepath.Join(dir, "last_session.json"), nil | ||
| } | ||
|
|
||
| // SaveLastSession records id as the last foregrounded session for project. | ||
| // Best-effort: persistence must never break session switching, and callers | ||
| // run outside any engine lock (file I/O). | ||
| func SaveLastSession(project, id string) { | ||
| if project == "" || id == "" || ValidateSessionID(id) != nil { | ||
| return | ||
| } | ||
| indexMu.Lock() | ||
| defer indexMu.Unlock() | ||
|
|
||
| p, err := lastSessionPath() | ||
| if err != nil { | ||
| return | ||
| } | ||
| var f lastSessionFile | ||
| if data, readErr := os.ReadFile(p); readErr == nil { | ||
| _ = json.Unmarshal(data, &f) // corrupt file → start fresh | ||
| } | ||
| if f.Projects == nil { | ||
| f.Projects = map[string]string{} | ||
| } | ||
| if f.Projects[project] == id { | ||
| return | ||
| } | ||
| f.Projects[project] = id | ||
|
|
||
| if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { | ||
| return | ||
| } | ||
| data, err := json.Marshal(&f) | ||
| if err != nil { | ||
| return | ||
| } | ||
| // tmp + rename (same pattern as the session index) so a crash mid-write | ||
| // never leaves a truncated file. | ||
| tmp := p + ".tmp" | ||
| if err := os.WriteFile(tmp, data, 0644); err != nil { | ||
| return | ||
| } | ||
| _ = os.Rename(tmp, p) | ||
| } | ||
|
|
||
| // LoadLastSession returns the last foregrounded session uuid for project, or | ||
| // "" when none is recorded — or when the recorded session no longer exists on | ||
| // disk (deleted, or a "new chat" that was never written), so callers fall | ||
| // back to a fresh session instead of resurrecting a stale id. | ||
| func LoadLastSession(project string) string { | ||
| if project == "" { | ||
| return "" | ||
| } | ||
| p, err := lastSessionPath() | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| data, err := os.ReadFile(p) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| var f lastSessionFile | ||
| if err := json.Unmarshal(data, &f); err != nil { | ||
| return "" | ||
| } | ||
| id := f.Projects[project] | ||
| if id == "" || ValidateSessionID(id) != nil { | ||
| return "" | ||
| } | ||
| dir, err := config.SessionsDir() | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| if _, err := os.Stat(filepath.Join(dir, id+".json")); err != nil { | ||
| return "" | ||
| } | ||
| return id | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package session | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/cnjack/jcode/internal/config" | ||
| ) | ||
|
|
||
| // TestLastSessionRoundTrip covers save → load keyed per project. | ||
| func TestLastSessionRoundTrip(t *testing.T) { | ||
| t.Setenv("HOME", t.TempDir()) | ||
|
Comment on lines
+12
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Fix home directory mocking for Windows test isolation. Go's
📍 Affects 1 file
🤖 Prompt for AI Agents |
||
|
|
||
| // Nothing recorded yet → empty. | ||
| if got := LoadLastSession("/proj/a"); got != "" { | ||
| t.Fatalf("expected empty before any save, got %q", got) | ||
| } | ||
|
|
||
| // The loader only accepts sessions whose JSONL exists (a "new chat" that | ||
| // was never written must not resurrect), so materialize the session files. | ||
| dir, err := config.SessionsDir() | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := os.MkdirAll(dir, 0755); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| for _, id := range []string{"11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"} { | ||
| if err := os.WriteFile(filepath.Join(dir, id+".json"), []byte("{}\n"), 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| } | ||
|
|
||
| SaveLastSession("/proj/a", "11111111-1111-1111-1111-111111111111") | ||
| SaveLastSession("/proj/b", "22222222-2222-2222-2222-222222222222") | ||
|
|
||
| if got := LoadLastSession("/proj/a"); got != "11111111-1111-1111-1111-111111111111" { | ||
| t.Fatalf("proj/a: got %q", got) | ||
| } | ||
| if got := LoadLastSession("/proj/b"); got != "22222222-2222-2222-2222-222222222222" { | ||
| t.Fatalf("proj/b: got %q", got) | ||
| } | ||
| if got := LoadLastSession("/proj/never-saved"); got != "" { | ||
| t.Fatalf("unknown project: expected empty, got %q", got) | ||
| } | ||
|
|
||
| // Overwrite moves the project's pointer. | ||
| SaveLastSession("/proj/a", "22222222-2222-2222-2222-222222222222") | ||
| if got := LoadLastSession("/proj/a"); got != "22222222-2222-2222-2222-222222222222" { | ||
| t.Fatalf("proj/a after overwrite: got %q", got) | ||
| } | ||
| } | ||
|
|
||
| // TestLastSessionSkipsStaleIDs: a recorded id whose session file disappeared | ||
| // (deleted conversation, or an empty chat that never hit disk) loads as "". | ||
| func TestLastSessionSkipsStaleIDs(t *testing.T) { | ||
| t.Setenv("HOME", t.TempDir()) | ||
|
|
||
| SaveLastSession("/proj/a", "33333333-3333-3333-3333-333333333333") // file never created | ||
| if got := LoadLastSession("/proj/a"); got != "" { | ||
| t.Fatalf("stale id: expected empty, got %q", got) | ||
| } | ||
| } | ||
|
|
||
| // TestLastSessionRejectsBadInput: empty/unsafe values are no-ops, not errors. | ||
| func TestLastSessionRejectsBadInput(t *testing.T) { | ||
| t.Setenv("HOME", t.TempDir()) | ||
|
|
||
| SaveLastSession("", "11111111-1111-1111-1111-111111111111") | ||
| SaveLastSession("/proj/a", "") | ||
| SaveLastSession("/proj/a", "../escape") | ||
| if got := LoadLastSession("/proj/a"); got != "" { | ||
| t.Fatalf("expected empty after bad saves, got %q", got) | ||
| } | ||
| if got := LoadLastSession(""); got != "" { | ||
| t.Fatalf("empty project: expected empty, got %q", got) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Wrap the returned error.
As per coding guidelines, use
fmt.Errorf("tool_name: %w", err)for wrapped errors in non-tool code.🛠️ Proposed fix
func lastSessionPath() (string, error) { dir, err := config.SessionsDir() if err != nil { - return "", err + return "", fmt.Errorf("session: %w", err) }📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines