diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index be9398a8..58674e37 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -40,13 +40,24 @@ Sessions are stored as **JSONL** files (one JSON object per line): ### Project Directory Encoding -Project names are filesystem-safe encoded: +New sessions are written to the same directory pi itself uses, so pi and SDK +clients that scan for the current project find them. `EncodeProjectName` mirrors +pi's `dist/core/session-manager.js`: strip one leading separator, then map every +`/`, `\` and `:` to `-`: ```go -EncodeProjectName("/Users/me/project") → "--Users-me-project--" -DecodeProjectName("--Users-me-project--") → "/Users/me/project" +EncodeProjectName("/home/neven/code/xyz.net") → "--home-neven-code-xyz.net--" +EncodeProjectName(`C:\Users\me\proj`) → "--C--Users-me-proj--" ``` +The mapping is lossy — a literal hyphen is indistinguishable from a separator — +so a directory name is not a reliable source for the path. Reads prefer the +`cwd` in the session JSONL header (`resolveLocation`, `ParseSummary`), the only +lossless record, and fall back to `DecodeProjectName` only for directories with +no valid session header. `DecodeProjectName` still understands the escape +encoding (`__` → `_`, `_-` → `/`) that older pi-web builds wrote, as well as the +pre-escape legacy encoding, so existing directories keep working. + ## Parse Flow ``` diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index 32f8d374..97b777fb 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -136,7 +136,7 @@ name, while pi-web itself continues listening only on localhost. │ │ ├── 2026-01-15T10-30-00.000Z_a1b2c3d4.jsonl │ │ ├── 2026-01-15T11-00-00.000Z_e5f6g7h8.jsonl │ │ └── … -│ └── --another--project--/ +│ └── --home-me-other--/ │ └── … ├── session-status/ │ ├── 2026-01-15T10-30-00.000Z_a1b2c3d4.jsonl ← terminal writes here diff --git a/internal/sessions/session.go b/internal/sessions/session.go index 25b47850..935f0629 100644 --- a/internal/sessions/session.go +++ b/internal/sessions/session.go @@ -589,24 +589,23 @@ func sessionHeaderKey(raw map[string]any) string { return id + "\x00" + timestamp + "\x00" + cwd } -// cleanProjectName reverses EncodeProjectName for display purposes. -// It handles both the new escape-based encoding (using _ as sentinel) and -// the legacy encoding (where - stood for /). +// cleanProjectName reverses EncodeProjectName for display purposes. The +// mapping is lossy, so this is only a fallback when a session has no cwd in +// its header (see applySummaryContext), which is authoritative. func cleanProjectName(dirName string) string { s := strings.TrimPrefix(dirName, "--") s = strings.TrimSuffix(s, "--") if win, ok := decodeWindowsBody(s); ok { return win } - s = decodeProjectBody(s) - return s + return decodeProjectBody(s) } -// decodeWindowsBody reverses the Windows dash encoding (see -// EncodeProjectName): both : and \ became -, so a drive letter is always -// followed by two dashes. C--Users-me → C:\Users\me. Paths with literal -// hyphens decode wrongly, exactly like the legacy Unix encoding; callers -// recover via the session-header cwd (see resolveLocation). +// decodeWindowsBody reverses the Windows body of EncodeProjectName: both : +// and \ became -, so a drive letter is always followed by two dashes. +// C--Users-me → C:\Users\me. Paths with literal hyphens decode wrongly, just +// like Unix paths; callers recover via the session-header cwd (see +// resolveLocation). func decodeWindowsBody(s string) (string, bool) { if len(s) >= 3 && s[1] == '-' && s[2] == '-' && (('A' <= s[0] && s[0] <= 'Z') || ('a' <= s[0] && s[0] <= 'z')) { @@ -615,50 +614,35 @@ func decodeWindowsBody(s string) (string, bool) { return "", false } -// EncodeProjectName converts an absolute filesystem path into a safe -// directory name by escaping / and _. The result is wrapped with "--" -// so callers can recognise encoded project directories. +// EncodeProjectName returns the session directory name pi itself uses for an +// absolute cwd, mirroring dist/core/session-manager.js: +// +// `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--` // -// /home/user/my-project → --home_-user_-my-project-- -// /home/user/_cache → --home_-user_-__cache-- +// One leading separator is stripped and every /, \ and : becomes -. Writing +// the same directory pi writes keeps current-project session discovery +// working for pi and for SDK clients that scan the sessions tree. // -// Windows-shaped paths (drive letter or backslashes) instead mirror pi's own -// encoding (dist/core/session-manager.js: strip one leading separator, then -// map every /, \ and : to -), because the escape-based scheme would leave -// : and \ in the name — both invalid in Windows directory names. +// /home/neven/code/xyz.net → --home-neven-code-xyz.net-- +// C:\Users\me\proj → --C--Users-me-proj-- // -// C:\Users\me\proj → --C--Users-me-proj-- +// The mapping is lossy: a literal hyphen is indistinguishable from a +// separator, so it cannot be reversed exactly. The session header cwd is the +// source of truth for recovering a path (see resolveLocation). func EncodeProjectName(path string) string { s := strings.TrimSpace(path) - if isWindowsPath(s) { - if len(s) > 0 && (s[0] == '/' || s[0] == '\\') { - s = s[1:] - } - s = strings.NewReplacer("/", "-", `\`, "-", ":", "-").Replace(s) - return "--" + s + "--" - } - s = strings.Trim(s, "/") - // Escape _ first, then /. Order matters: we must double _ before we - // introduce any new _ in the / escape sequence. - s = strings.ReplaceAll(s, "_", "__") - s = strings.ReplaceAll(s, "/", "_-") - return "--" + s + "--" -} - -// isWindowsPath reports whether path is Windows-shaped: a drive letter -// ("C:...") or any backslash separator. Keying off the path's shape rather -// than runtime.GOOS keeps the encoding deterministic and testable everywhere. -func isWindowsPath(path string) bool { - if len(path) >= 2 && path[1] == ':' && - (('A' <= path[0] && path[0] <= 'Z') || ('a' <= path[0] && path[0] <= 'z')) { - return true + if len(s) > 0 && (s[0] == '/' || s[0] == '\\') { + s = s[1:] } - return strings.Contains(path, `\`) + s = strings.NewReplacer("/", "-", `\`, "-", ":", "-").Replace(s) + return "--" + s + "--" } -// DecodeProjectName reverses EncodeProjectName. It accepts both the -// new escape-based encoding and the legacy encoding (where - meant /) -// so that existing session directories continue to work. +// DecodeProjectName is the best-effort inverse of EncodeProjectName, used only +// when no session header cwd is available. It accepts every encoding pi-web has +// written: pi's native dash encoding and pi-web's older escape encoding +// (__ → _, _- → /). Native names are ambiguous, so the header cwd wins when it +// exists. func DecodeProjectName(dirName string) string { s := strings.TrimPrefix(dirName, "--") s = strings.TrimSuffix(s, "--") @@ -672,19 +656,18 @@ func DecodeProjectName(dirName string) string { return s } -// decodeProjectBody decodes the content between the "--" wrappers. -// New format (contains _): __ → _, _- → / -// Legacy format (no _): - → / +// decodeProjectBody decodes the content between the "--" wrappers. The old +// pi-web escape encoding is tried first when its markers are present; otherwise +// every - was a separator (pi's native encoding and the pre-escape legacy +// encoding, which are identical). func decodeProjectBody(s string) string { - if strings.Contains(s, "_") { - // New escape-based encoding. Order: unescape / first, then _. + if strings.Contains(s, "_-") || strings.Contains(s, "__") { + // Escape-based encoding. Order: unescape / first, then _. s = strings.ReplaceAll(s, "_-", "/") s = strings.ReplaceAll(s, "__", "_") - } else { - // Legacy encoding: every - was a /. - s = strings.ReplaceAll(s, "-", "/") + return s } - return s + return strings.ReplaceAll(s, "-", "/") } const maxRecentLocations = 10 @@ -730,39 +713,36 @@ func ListRecentLocations(sessionsDir string) ([]string, error) { return locations, nil } -// resolveLocation returns the projects absolute path for the given -// project directory name. It first tries DecodeProjectName; if the -// result exists on disk it is returned directly. Otherwise it falls -// back to reading the cwd from a session JSONL file inside the -// directory — this recovers legacy-encoded directories whose names -// contain literal hyphens that DecodeProjectName misinterprets as -// path separators. +// resolveLocation returns the project's absolute path for the given project +// directory name. The session header cwd is authoritative because +// EncodeProjectName is lossy (a literal hyphen looks like a separator). Only +// when the directory has no valid session header does it fall back to decoding +// the directory name. func resolveLocation(sessionsDir, dirName string) string { - loc := DecodeProjectName(dirName) - if loc != "" { - if info, err := os.Stat(loc); err == nil && info.IsDir() { - return loc - } - } - // Decoded path doesn't exist (or decoded to empty). Try to recover - // the real cwd from a session file inside the project directory. - cwd := readSessionCWD(filepath.Join(sessionsDir, dirName)) - if cwd != "" { + if cwd := readSessionCWD(filepath.Join(sessionsDir, dirName)); cwd != "" { return cwd } - return loc + return DecodeProjectName(dirName) } -// readSessionCWD opens a *.jsonl file in dir, reads its session header -// line, and returns the cwd field. Returns "" on any error. Any file -// in the directory will do — all sessions in the same project share -// the same cwd. +// readSessionCWD returns the cwd from the first valid session header found in +// any *.jsonl file in dir, or "" when none exists. The header cwd is the only +// lossless record of a project path, so it is preferred over decoding dir. func readSessionCWD(dir string) string { matches, err := filepath.Glob(filepath.Join(dir, "*.jsonl")) - if err != nil || len(matches) == 0 { + if err != nil { return "" } - f, err := os.Open(matches[0]) + for _, match := range matches { + if cwd := sessionHeaderCWD(match); cwd != "" { + return cwd + } + } + return "" +} + +func sessionHeaderCWD(path string) string { + f, err := os.Open(path) if err != nil { return "" } @@ -781,11 +761,14 @@ func readSessionCWD(dir string) string { if err := json.Unmarshal([]byte(line), &raw); err != nil { continue } + // Session headers belong at the start. Once the first parseable + // record is not a valid header, the file has no usable header; a later + // session-shaped line (e.g. a pasted transcript) must not win, and we + // must not scan the rest of a potentially huge headerless file. if raw.Type == "session" && raw.CWD != "" { return raw.CWD } - // Only the first (session) line matters. - break + return "" } return "" } diff --git a/internal/sessions/session_test.go b/internal/sessions/session_test.go index 4051c349..a88539a4 100644 --- a/internal/sessions/session_test.go +++ b/internal/sessions/session_test.go @@ -20,15 +20,20 @@ func TestEncodeProjectName(t *testing.T) { input string expected string }{ - {"/Users/setkyar", "--Users_-setkyar--"}, - {"/home/user/project", "--home_-user_-project--"}, - {"/a/b/c/d", "--a_-b_-c_-d--"}, - {"/Users/setkyar/pi-web", "--Users_-setkyar_-pi-web--"}, - {"/Users/setkyar/my-project", "--Users_-setkyar_-my-project--"}, - {"/Users/setkyar/_cache", "--Users_-setkyar_-__cache--"}, - {"/a/_b/_c", "--a_-__b_-__c--"}, - // Windows-shaped paths mirror pi's encoding (/, \ and : all map to -) - // so the result is a valid Windows directory name. + // Native pi encoding: strip one leading separator, then map every + // /, \ and : to - (dist/core/session-manager.js). + {"/Users/setkyar", "--Users-setkyar--"}, + {"/home/user/project", "--home-user-project--"}, + // The issue's example: a dot in the last segment is untouched. + {"/home/neven/code/xyz.net", "--home-neven-code-xyz.net--"}, + {"/a/b/c/d", "--a-b-c-d--"}, + {"/Users/setkyar/pi-web", "--Users-setkyar-pi-web--"}, + // Literal hyphens and underscores are preserved verbatim; the result is + // therefore ambiguous, which is why the header cwd is authoritative. + {"/Users/setkyar/my-project", "--Users-setkyar-my-project--"}, + {"/Users/setkyar/_cache", "--Users-setkyar-_cache--"}, + {"/a/_b/_c", "--a-_b-_c--"}, + // Windows-shaped paths use the same mapping, so : and \ become - too. {`C:\Users\me\proj`, "--C--Users-me-proj--"}, {`c:\work`, "--c--work--"}, {`C:/Users/me/proj`, "--C--Users-me-proj--"}, @@ -47,16 +52,20 @@ func TestDecodeProjectName(t *testing.T) { input string expected string }{ - // New format + // Native encoding: - always decodes to /. + {"--Users-setkyar--", "/Users/setkyar"}, + {"--home-user-project--", "/home/user/project"}, + {"--home-neven-code-xyz.net--", "/home/neven/code/xyz.net"}, + // A lone underscore is not a marker, so it survives the native decode. + {"--Users-setkyar-_cache--", "/Users/setkyar/_cache"}, + // Literal hyphens are indistinguishable from separators in native names. + {"--Users-setkyar-my-project--", "/Users/setkyar/my/project"}, + // Custom escape encoding written by older pi-web builds, still readable. {"--Users_-setkyar--", "/Users/setkyar"}, {"--home_-user_-project--", "/home/user/project"}, {"--a_-b_-c_-d--", "/a/b/c/d"}, {"--Users_-setkyar_-my-project--", "/Users/setkyar/my-project"}, {"--Users_-setkyar_-__cache--", "/Users/setkyar/_cache"}, - // Legacy format (no _ in body) — backward compatible. - {"--Users-setkyar--", "/Users/setkyar"}, - {"--home-user-project--", "/home/user/project"}, - {"--a-b-c-d--", "/a/b/c/d"}, // Windows dash encoding: drive letter followed by two dashes // (both : and \ became -). {"--C--Users-me-proj--", `C:\Users\me\proj`}, @@ -71,16 +80,12 @@ func TestDecodeProjectName(t *testing.T) { } } -func TestEncodeDecodeRoundTrip(t *testing.T) { +func TestEncodeDecodeRoundTripWithoutAmbiguousCharacters(t *testing.T) { paths := []string{ "/Users/setkyar", "/home/user/project", "/a/b/c/d", - "/Users/setkyar/my-project", - "/Users/setkyar/_cache", - "/a/_b/_c", - "/project-with-hyphens/sub_dir", - "/underscore_test/path", + "/home/neven/code/xyz.net", } for _, p := range paths { encoded := EncodeProjectName(p) @@ -91,6 +96,23 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { } } +func TestDecodeProjectNameNativeNamesAreLossy(t *testing.T) { + // Native encoding cannot round-trip these; the session header cwd is the + // source of truth. This documents the ambiguity instead of asserting + // losslessness. + paths := []string{ + "/Users/setkyar/my-project", + "/project-with-hyphens/sub_dir", + "/foo_/bar", + "/foo__bar/baz", + } + for _, p := range paths { + if decoded := DecodeProjectName(EncodeProjectName(p)); decoded == p { + t.Errorf("expected lossy round-trip for %q, got %q", p, decoded) + } + } +} + func TestSortSummariesByActivityOrdersNewestFirst(t *testing.T) { summaries := []SessionSummary{ {ID: "old", LastActivity: "2026-02-27T15:13:25.383Z"}, @@ -185,17 +207,17 @@ func TestListRecentLocationsReturnsNewestBoundedLocations(t *testing.T) { } } -func TestListRecentLocationsRecoversLegacyHyphenatedPaths(t *testing.T) { +func TestListRecentLocationsRecoversHyphenatedNativePaths(t *testing.T) { tmp := t.TempDir() - // Simulate a legacy-encoded directory for a path that contains - // literal hyphens: /tmp/my-project → --tmp-my-project-- - legacyDir := filepath.Join(tmp, "--tmp-my-project--") - if err := os.MkdirAll(legacyDir, 0755); err != nil { + // Native pi encoding for a path with a literal hyphen: + // /tmp/my-project → --tmp-my-project-- + nativeDir := filepath.Join(tmp, "--tmp-my-project--") + if err := os.MkdirAll(nativeDir, 0755); err != nil { t.Fatal(err) } // Write a session file with the real cwd in the header. - sessionPath := filepath.Join(legacyDir, "2026-05-08T10-00-00.000Z_abc.jsonl") + sessionPath := filepath.Join(nativeDir, "2026-05-08T10-00-00.000Z_abc.jsonl") content := `{"type":"session","version":3,"id":"abc","timestamp":"2026-05-08T10:00:00Z","cwd":"/tmp/my-project"}` + "\n" if err := os.WriteFile(sessionPath, []byte(content), 0644); err != nil { t.Fatal(err) @@ -205,26 +227,58 @@ func TestListRecentLocationsRecoversLegacyHyphenatedPaths(t *testing.T) { if err != nil { t.Fatal(err) } - if len(locations) == 0 { - t.Fatal("expected at least 1 location") + if len(locations) != 1 { + t.Fatalf("expected exactly 1 location, got %#v", locations) } if locations[0] != "/tmp/my-project" { t.Fatalf("expected recovered path /tmp/my-project, got %q", locations[0]) } } -func TestResolveLocationReturnsDecodedPathWhenOnDisk(t *testing.T) { +// legacyEncodeProjectName reproduces the escape encoding older pi-web builds +// wrote (__ → _, _- → /). Reads must keep supporting these directories. +func legacyEncodeProjectName(path string) string { + s := strings.Trim(path, "/") + s = strings.ReplaceAll(s, "_", "__") + s = strings.ReplaceAll(s, "/", "_-") + return "--" + s + "--" +} + +func TestResolveLocationReadsCustomEncodedDirectory(t *testing.T) { tmp := t.TempDir() + sessionsDir := filepath.Join(tmp, "sessions") + // Unix-shaped literal so the assertion is identical on every platform; + // resolveLocation reads the header cwd, which need not exist on disk. + const realPath = "/Users/setkyar/custom-project_dir" - // Create a real project directory so os.Stat succeeds. Hyphen-free name: - // on Windows the dash encoding can't round-trip literal hyphens, and this - // test exercises the decode-only path (no session file to recover from). + projectDir := filepath.Join(sessionsDir, legacyEncodeProjectName(realPath)) + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatal(err) + } + content := `{"type":"session","version":3,"id":"abc","timestamp":"2026-05-08T10:00:00Z","cwd":"` + realPath + `"}` + "\n" + if err := os.WriteFile(filepath.Join(projectDir, "s.jsonl"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + locations, err := ListRecentLocations(sessionsDir) + if err != nil { + t.Fatal(err) + } + if len(locations) != 1 || locations[0] != realPath { + t.Fatalf("locations = %#v, want [%q]", locations, realPath) + } +} + +func TestResolveLocationFallsBackToDecodedNameWithoutHeader(t *testing.T) { + tmp := t.TempDir() + + // Hyphen-free name: native decode is exact for it, and this exercises the + // no-session-header fallback path. realPath := filepath.Join(tmp, "myproject") if err := os.MkdirAll(realPath, 0755); err != nil { t.Fatal(err) } - // Create the new-format encoded directory under a sessions root. sessionsDir := filepath.Join(tmp, "sessions") encodedDir := filepath.Join(sessionsDir, EncodeProjectName(realPath)) if err := os.MkdirAll(encodedDir, 0755); err != nil { @@ -235,14 +289,85 @@ func TestResolveLocationReturnsDecodedPathWhenOnDisk(t *testing.T) { if err != nil { t.Fatal(err) } - if len(locations) == 0 { - t.Fatal("expected at least 1 location") + if len(locations) != 1 { + t.Fatalf("expected exactly 1 location, got %#v", locations) } if locations[0] != realPath { t.Fatalf("expected %q, got %q", realPath, locations[0]) } } +func TestResolveLocationPrefersHeaderCwdOverLossyDecode(t *testing.T) { + tmp := t.TempDir() + sessionsDir := filepath.Join(tmp, "sessions") + + // Both /…/my/project and /…/my-project exist, so a name-only decode is + // plausible but wrong; the header cwd must win. + split := filepath.Join(tmp, "my", "project") + joined := filepath.Join(tmp, "my-project") + for _, p := range []string{split, joined} { + if err := os.MkdirAll(p, 0755); err != nil { + t.Fatal(err) + } + } + + projectDir := filepath.Join(sessionsDir, EncodeProjectName(joined)) + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatal(err) + } + cwdJSON, err := json.Marshal(joined) + if err != nil { + t.Fatal(err) + } + content := `{"type":"session","timestamp":"2026-05-08T10:00:00Z","cwd":` + string(cwdJSON) + `}` + "\n" + if err := os.WriteFile(filepath.Join(projectDir, "s.jsonl"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + locations, err := ListRecentLocations(sessionsDir) + if err != nil { + t.Fatal(err) + } + if len(locations) != 1 || locations[0] != joined { + t.Fatalf("locations = %#v, want [%q] (not the lossy decode)", locations, joined) + } +} + +func TestReadSessionCWDStopsAtFirstNonHeaderRecord(t *testing.T) { + tmp := t.TempDir() + + // a.jsonl starts with a message and only later carries a session-shaped + // line; that later line must be ignored, and the file must not be scanned + // further. It sorts before b.jsonl, so accepting the fake header would + // change the result below. + fake := `{"type":"message","timestamp":"2026-05-08T10:00:00Z","message":{"role":"user","content":"hi"}}` + "\n" + + `{"type":"session","timestamp":"2026-05-08T10:00:01Z","cwd":"/fake/later/header"}` + "\n" + if err := os.WriteFile(filepath.Join(tmp, "a.jsonl"), []byte(fake), 0644); err != nil { + t.Fatal(err) + } + + // A separate file whose first record is a proper header is still found. + good := `{"type":"session","timestamp":"2026-05-08T10:00:00Z","cwd":"/real/project"}` + "\n" + if err := os.WriteFile(filepath.Join(tmp, "b.jsonl"), []byte(good), 0644); err != nil { + t.Fatal(err) + } + + if got := readSessionCWD(tmp); got != "/real/project" { + t.Fatalf("readSessionCWD = %q, want %q", got, "/real/project") + } +} + +// piEncodeProjectDir is an independent restatement of pi's directory encoding +// (dist/core/session-manager.js) used as a test oracle. +func piEncodeProjectDir(path string) string { + s := path + if len(s) > 0 && (s[0] == '/' || s[0] == '\\') { + s = s[1:] + } + s = strings.NewReplacer("/", "-", `\`, "-", ":", "-").Replace(s) + return "--" + s + "--" +} + func TestCreateSessionFile(t *testing.T) { tmpDir := t.TempDir() sessDir := filepath.Join(tmpDir, "sessions") @@ -283,6 +408,23 @@ func TestCreateSessionFile(t *testing.T) { } } +func TestCreateSessionFileUsesNativePiProjectDirectory(t *testing.T) { + tmp := t.TempDir() + sessDir := filepath.Join(tmp, "sessions") + // A hyphen and an underscore: native pi leaves both verbatim. + projectPath := filepath.Join(tmp, "native-project_dir") + + id, err := createSessionFile(sessDir, projectPath) + if err != nil { + t.Fatalf("CreateSessionFile failed: %v", err) + } + + wantDir := piEncodeProjectDir(projectPath) + if _, err := os.Stat(filepath.Join(sessDir, wantDir, id)); err != nil { + t.Fatalf("session not written to native pi directory %q: %v", wantDir, err) + } +} + func TestCreateSessionFileAcceptsLegitimateDoubleDotInName(t *testing.T) { tmp := t.TempDir() dir := filepath.Join(tmp, "..hidden-project") @@ -782,7 +924,7 @@ func TestForkSessionFile(t *testing.T) { t.Fatalf("CreateSessionFile failed: %v", err) } - projectDir := filepath.Join(sessDir, EncodeProjectName(projectPath)) + projectDir := filepath.Join(sessDir, piEncodeProjectDir(projectPath)) sourcePath := filepath.Join(projectDir, id) // Append some tree entries @@ -854,7 +996,7 @@ func TestCloneSessionFile(t *testing.T) { t.Fatalf("CreateSessionFile failed: %v", err) } - projectDir := filepath.Join(sessDir, EncodeProjectName(projectPath)) + projectDir := filepath.Join(sessDir, piEncodeProjectDir(projectPath)) sourcePath := filepath.Join(projectDir, id) entries := []string{ @@ -920,7 +1062,7 @@ func TestForkSessionFileEntryNotFound(t *testing.T) { t.Fatalf("CreateSessionFile failed: %v", err) } - projectDir := filepath.Join(sessDir, EncodeProjectName(projectPath)) + projectDir := filepath.Join(sessDir, piEncodeProjectDir(projectPath)) sourcePath := filepath.Join(projectDir, id) now := func() time.Time { return time.Date(2026, 5, 8, 11, 0, 0, 0, time.UTC) }