session-ls 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {session_ls-0.2.0/src/session_ls.egg-info → session_ls-0.2.1}/PKG-INFO +12 -5
- {session_ls-0.2.0 → session_ls-0.2.1}/README.md +7 -4
- {session_ls-0.2.0 → session_ls-0.2.1}/pyproject.toml +12 -2
- {session_ls-0.2.0 → session_ls-0.2.1}/src/session_ls/__init__.py +11 -1
- {session_ls-0.2.0 → session_ls-0.2.1/src/session_ls.egg-info}/PKG-INFO +12 -5
- {session_ls-0.2.0 → session_ls-0.2.1}/src/session_ls.egg-info/SOURCES.txt +2 -0
- session_ls-0.2.1/src/session_ls.egg-info/requires.txt +5 -0
- session_ls-0.2.1/tests/test_api.py +338 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/tests/test_cli.py +9 -3
- {session_ls-0.2.0 → session_ls-0.2.1}/tests/test_session_ls.py +4 -1
- {session_ls-0.2.0 → session_ls-0.2.1}/LICENSE +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/MANIFEST.in +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/man/session-ls.1 +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/setup.cfg +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/src/session_ls/__main__.py +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/src/session_ls/api.py +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/src/session_ls/storage.py +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/src/session_ls.egg-info/dependency_links.txt +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/src/session_ls.egg-info/entry_points.txt +0 -0
- {session_ls-0.2.0 → session_ls-0.2.1}/src/session_ls.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: session-ls
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: List and search session history across coding agents (pi, codex, claude, cursor)
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -37,6 +37,10 @@ Classifier: Topic :: Utilities
|
|
|
37
37
|
Requires-Python: >=3.9
|
|
38
38
|
Description-Content-Type: text/markdown
|
|
39
39
|
License-File: LICENSE
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
42
|
+
Requires-Dist: build<2,>=1; extra == "dev"
|
|
43
|
+
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
|
|
40
44
|
Dynamic: license-file
|
|
41
45
|
|
|
42
46
|
# session-ls
|
|
@@ -64,15 +68,18 @@ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
|
|
|
64
68
|
- **Plain search, no semantics.** No index, no embeddings, no network.
|
|
65
69
|
Matching is literal substring comparison. Decide what's relevant
|
|
66
70
|
yourself - or hand the file paths to an LLM.
|
|
67
|
-
- **Lightweight, zero dependencies.** Pure stdlib,
|
|
71
|
+
- **Lightweight, zero dependencies.** Pure stdlib: a legacy `session-ls` CLI, and
|
|
72
|
+
a versioned typed API (`session_ls.api`) for programs such as 4top.
|
|
68
73
|
- **Extensible.** Adding another agent is one `REGISTRY` entry plus two
|
|
69
74
|
small functions (see below).
|
|
70
75
|
|
|
71
76
|
## Install
|
|
72
77
|
|
|
73
78
|
```bash
|
|
74
|
-
|
|
75
|
-
pipx install
|
|
79
|
+
uv tool install session-ls # or:
|
|
80
|
+
pipx install session-ls # isolated environment
|
|
81
|
+
pip install session-ls # into the current environment
|
|
82
|
+
pip install -e '.[dev]' # from a checkout, to work on it
|
|
76
83
|
```
|
|
77
84
|
|
|
78
85
|
Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
|
|
@@ -118,7 +125,7 @@ session-ls websocket --json | jq -r .file # feed paths to other tools
|
|
|
118
125
|
|
|
119
126
|
## Adding an agent
|
|
120
127
|
|
|
121
|
-
Append an entry to `REGISTRY` in `
|
|
128
|
+
Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
|
|
122
129
|
|
|
123
130
|
1. a glob of session files
|
|
124
131
|
2. `meta_parser(f, head) -> (cwd, started_iso) | None`
|
|
@@ -23,15 +23,18 @@ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
|
|
|
23
23
|
- **Plain search, no semantics.** No index, no embeddings, no network.
|
|
24
24
|
Matching is literal substring comparison. Decide what's relevant
|
|
25
25
|
yourself - or hand the file paths to an LLM.
|
|
26
|
-
- **Lightweight, zero dependencies.** Pure stdlib,
|
|
26
|
+
- **Lightweight, zero dependencies.** Pure stdlib: a legacy `session-ls` CLI, and
|
|
27
|
+
a versioned typed API (`session_ls.api`) for programs such as 4top.
|
|
27
28
|
- **Extensible.** Adding another agent is one `REGISTRY` entry plus two
|
|
28
29
|
small functions (see below).
|
|
29
30
|
|
|
30
31
|
## Install
|
|
31
32
|
|
|
32
33
|
```bash
|
|
33
|
-
|
|
34
|
-
pipx install
|
|
34
|
+
uv tool install session-ls # or:
|
|
35
|
+
pipx install session-ls # isolated environment
|
|
36
|
+
pip install session-ls # into the current environment
|
|
37
|
+
pip install -e '.[dev]' # from a checkout, to work on it
|
|
35
38
|
```
|
|
36
39
|
|
|
37
40
|
Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
|
|
@@ -77,7 +80,7 @@ session-ls websocket --json | jq -r .file # feed paths to other tools
|
|
|
77
80
|
|
|
78
81
|
## Adding an agent
|
|
79
82
|
|
|
80
|
-
Append an entry to `REGISTRY` in `
|
|
83
|
+
Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
|
|
81
84
|
|
|
82
85
|
1. a glob of session files
|
|
83
86
|
2. `meta_parser(f, head) -> (cwd, started_iso) | None`
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "session-ls"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "List and search session history across coding agents (pi, codex, claude, cursor)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -25,7 +25,7 @@ Repository = "https://github.com/4ier/session-ls"
|
|
|
25
25
|
Issues = "https://github.com/4ier/session-ls/issues"
|
|
26
26
|
|
|
27
27
|
[project.scripts]
|
|
28
|
-
session-ls = "session_ls:cli"
|
|
28
|
+
session-ls = "session_ls:cli" # cli() installs the SIGPIPE policy, then runs main()
|
|
29
29
|
|
|
30
30
|
[tool.setuptools]
|
|
31
31
|
package-dir = { "" = "src" }
|
|
@@ -34,5 +34,15 @@ packages = ["session_ls"]
|
|
|
34
34
|
[tool.setuptools.data-files]
|
|
35
35
|
"share/man/man1" = ["man/session-ls.1"]
|
|
36
36
|
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = ["pytest>=8,<10", "build>=1,<2", "ruff>=0.16,<0.17"]
|
|
39
|
+
|
|
37
40
|
[tool.pytest.ini_options]
|
|
38
41
|
testpaths = ["tests"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
target-version = "py39"
|
|
45
|
+
line-length = 100
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E4", "E7", "E9", "F", "I"]
|
|
@@ -11,7 +11,17 @@ Add a new agent by appending one entry to REGISTRY:
|
|
|
11
11
|
- user-text-extractor(line) -> first real user message text or ''
|
|
12
12
|
('' = keep scanning; drives the early-exit read)
|
|
13
13
|
"""
|
|
14
|
-
import argparse
|
|
14
|
+
import argparse
|
|
15
|
+
import glob
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import signal
|
|
20
|
+
|
|
21
|
+
# The CLI tests replace this module attribute to prove that nothing shells out, so
|
|
22
|
+
# the name has to exist even though the parser itself never spawns a process.
|
|
23
|
+
import subprocess # noqa: F401
|
|
24
|
+
import tempfile
|
|
15
25
|
from datetime import datetime, timezone
|
|
16
26
|
|
|
17
27
|
HOME = os.path.expanduser("~")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: session-ls
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: List and search session history across coding agents (pi, codex, claude, cursor)
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -37,6 +37,10 @@ Classifier: Topic :: Utilities
|
|
|
37
37
|
Requires-Python: >=3.9
|
|
38
38
|
Description-Content-Type: text/markdown
|
|
39
39
|
License-File: LICENSE
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
42
|
+
Requires-Dist: build<2,>=1; extra == "dev"
|
|
43
|
+
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
|
|
40
44
|
Dynamic: license-file
|
|
41
45
|
|
|
42
46
|
# session-ls
|
|
@@ -64,15 +68,18 @@ codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
|
|
|
64
68
|
- **Plain search, no semantics.** No index, no embeddings, no network.
|
|
65
69
|
Matching is literal substring comparison. Decide what's relevant
|
|
66
70
|
yourself - or hand the file paths to an LLM.
|
|
67
|
-
- **Lightweight, zero dependencies.** Pure stdlib,
|
|
71
|
+
- **Lightweight, zero dependencies.** Pure stdlib: a legacy `session-ls` CLI, and
|
|
72
|
+
a versioned typed API (`session_ls.api`) for programs such as 4top.
|
|
68
73
|
- **Extensible.** Adding another agent is one `REGISTRY` entry plus two
|
|
69
74
|
small functions (see below).
|
|
70
75
|
|
|
71
76
|
## Install
|
|
72
77
|
|
|
73
78
|
```bash
|
|
74
|
-
|
|
75
|
-
pipx install
|
|
79
|
+
uv tool install session-ls # or:
|
|
80
|
+
pipx install session-ls # isolated environment
|
|
81
|
+
pip install session-ls # into the current environment
|
|
82
|
+
pip install -e '.[dev]' # from a checkout, to work on it
|
|
76
83
|
```
|
|
77
84
|
|
|
78
85
|
Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
|
|
@@ -118,7 +125,7 @@ session-ls websocket --json | jq -r .file # feed paths to other tools
|
|
|
118
125
|
|
|
119
126
|
## Adding an agent
|
|
120
127
|
|
|
121
|
-
Append an entry to `REGISTRY` in `
|
|
128
|
+
Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
|
|
122
129
|
|
|
123
130
|
1. a glob of session files
|
|
124
131
|
2. `meta_parser(f, head) -> (cwd, started_iso) | None`
|
|
@@ -11,6 +11,8 @@ src/session_ls.egg-info/PKG-INFO
|
|
|
11
11
|
src/session_ls.egg-info/SOURCES.txt
|
|
12
12
|
src/session_ls.egg-info/dependency_links.txt
|
|
13
13
|
src/session_ls.egg-info/entry_points.txt
|
|
14
|
+
src/session_ls.egg-info/requires.txt
|
|
14
15
|
src/session_ls.egg-info/top_level.txt
|
|
16
|
+
tests/test_api.py
|
|
15
17
|
tests/test_cli.py
|
|
16
18
|
tests/test_session_ls.py
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from session_ls.api import (
|
|
12
|
+
HistoryIndex,
|
|
13
|
+
Root,
|
|
14
|
+
clean_text,
|
|
15
|
+
contains_literal_file,
|
|
16
|
+
excerpt,
|
|
17
|
+
history_key,
|
|
18
|
+
open_source,
|
|
19
|
+
query_terms,
|
|
20
|
+
read_metadata,
|
|
21
|
+
search_full,
|
|
22
|
+
)
|
|
23
|
+
from session_ls.storage import StorageError, atomic_json, file_lock, read_json
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_pi(tmp_path, title="中文 [red]literal.*", native=None):
|
|
27
|
+
root = tmp_path / "pi"
|
|
28
|
+
file = root / "sessions/cwd/run.jsonl"
|
|
29
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
records = [{"type": "session", "id": native or str(uuid.uuid4()), "cwd": str(tmp_path),
|
|
31
|
+
"timestamp": "2026-09-24T00:00:00Z"},
|
|
32
|
+
{"type": "message", "message": {"role": "user", "content": [{"type": "text", "text": title}]}}]
|
|
33
|
+
file.write_text("\n".join(json.dumps(value) for value in records) + "\n")
|
|
34
|
+
return Root("pi", str(root)), file
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_import_does_not_change_sigpipe_or_touch_files(tmp_path):
|
|
38
|
+
code = "import signal; a=signal.getsignal(signal.SIGPIPE); import session_ls,session_ls.api; assert signal.getsignal(signal.SIGPIPE)==a"
|
|
39
|
+
result = subprocess.run([sys.executable, "-c", code], env={**os.environ, "HOME": str(tmp_path)}, capture_output=True)
|
|
40
|
+
assert result.returncode == 0
|
|
41
|
+
assert list(tmp_path.iterdir()) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_api_identity_unicode_and_literal_search(tmp_path):
|
|
45
|
+
root, file = make_pi(tmp_path)
|
|
46
|
+
index = HistoryIndex([root], tmp_path / "cache/data.json", "host")
|
|
47
|
+
result = index.scan()
|
|
48
|
+
assert len(result.records) == 1 and not result.issues
|
|
49
|
+
row = result.records[0]
|
|
50
|
+
assert row.native_id and row.cwd_quality == "native" and row.can_resume
|
|
51
|
+
assert row.key == history_key("host", "pi", root.path, row.native_id)
|
|
52
|
+
assert search_full(result.records, "中文").records
|
|
53
|
+
assert search_full(result.records, '"literal.*"').records
|
|
54
|
+
assert not search_full(result.records, '"literal.+"').records
|
|
55
|
+
assert contains_literal_file(str(file), "中文")
|
|
56
|
+
assert not contains_literal_file(str(file), "no-such-term")
|
|
57
|
+
assert "[red]" in excerpt(row).lines[0]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_query_terms_are_literal_and_quoted():
|
|
61
|
+
assert query_terms('retry "HELLO WORLD" 中文') == ["retry", "hello world", "中文"]
|
|
62
|
+
with pytest.raises(ValueError):
|
|
63
|
+
query_terms('"unfinished')
|
|
64
|
+
assert query_terms('"unfinished', tolerant=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize("value", ["\x1b[31mred\x1b[0m", "a\x1b]52;c;YWJj\x07b", "a\u202eb", "a\x00b", "a\x1bPbad\x1b\\b"])
|
|
68
|
+
def test_control_sequences_are_inert(value):
|
|
69
|
+
cleaned = clean_text(value)
|
|
70
|
+
assert "\x1b" not in cleaned and "\u202e" not in cleaned and "\x00" not in cleaned
|
|
71
|
+
assert "YWJj" not in cleaned
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_half_lines_blank_lines_and_cache_invalidation(tmp_path):
|
|
75
|
+
root, file = make_pi(tmp_path)
|
|
76
|
+
file.write_text(file.read_text().replace('\n', '\n\n') + '{"incomplete":')
|
|
77
|
+
index = HistoryIndex([root], tmp_path / "cache/data.json", "host")
|
|
78
|
+
first = index.scan()
|
|
79
|
+
assert len(first.records) == 1
|
|
80
|
+
cache = tmp_path / "cache/data.json"
|
|
81
|
+
stamp = cache.stat().st_mtime_ns
|
|
82
|
+
assert index.scan().records == first.records
|
|
83
|
+
assert cache.stat().st_mtime_ns == stamp
|
|
84
|
+
file.unlink()
|
|
85
|
+
assert index.scan().records == []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_bad_cache_rebuilds(tmp_path):
|
|
89
|
+
root, file = make_pi(tmp_path)
|
|
90
|
+
cache = tmp_path / "cache/data.json"
|
|
91
|
+
index = HistoryIndex([root], cache, "host")
|
|
92
|
+
index.scan()
|
|
93
|
+
cache.write_text("{broken")
|
|
94
|
+
result = index.scan()
|
|
95
|
+
assert len(result.records) == 1
|
|
96
|
+
assert isinstance(read_json(cache), dict)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_partial_budget_never_fabricates_absence(tmp_path):
|
|
100
|
+
root, file = make_pi(tmp_path)
|
|
101
|
+
record, _ = read_metadata(root, str(file), "host", max_bytes=60, max_lines=1)
|
|
102
|
+
assert record is not None and record.status == "partial"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_source_symlink_and_escape_rejected(tmp_path):
|
|
106
|
+
root, file = make_pi(tmp_path)
|
|
107
|
+
outside = tmp_path / "outside.jsonl"
|
|
108
|
+
outside.write_text("secret")
|
|
109
|
+
link = file.parent / "linked.jsonl"
|
|
110
|
+
link.symlink_to(outside)
|
|
111
|
+
with pytest.raises((OSError, ValueError)):
|
|
112
|
+
with open_source(str(link), root.path):
|
|
113
|
+
pass
|
|
114
|
+
with pytest.raises((OSError, ValueError)):
|
|
115
|
+
with open_source(str(outside), root.path):
|
|
116
|
+
pass
|
|
117
|
+
result = HistoryIndex([root], None, "host").scan()
|
|
118
|
+
assert len(result.records) == 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_same_native_id_in_different_roots_stays_distinct(tmp_path):
|
|
122
|
+
native = str(uuid.uuid4())
|
|
123
|
+
root1, _ = make_pi(tmp_path / "a", native=native)
|
|
124
|
+
root2, _ = make_pi(tmp_path / "b", native=native)
|
|
125
|
+
result = HistoryIndex([root1, root2], None, "host").scan()
|
|
126
|
+
assert len({r.key for r in result.records}) == 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_atomic_permissions_symlinks_and_locks(tmp_path):
|
|
130
|
+
target = tmp_path / "private/state.json"
|
|
131
|
+
atomic_json(target, {"ok": 1})
|
|
132
|
+
assert target.stat().st_mode & 0o777 == 0o600
|
|
133
|
+
assert target.parent.stat().st_mode & 0o777 == 0o700
|
|
134
|
+
assert read_json(target) == {"ok": 1}
|
|
135
|
+
linked_parent = tmp_path / "alias"
|
|
136
|
+
linked_parent.symlink_to(target.parent, target_is_directory=True)
|
|
137
|
+
with pytest.raises(StorageError):
|
|
138
|
+
atomic_json(linked_parent / "other.json", {})
|
|
139
|
+
lock = target.parent / "x.lock"
|
|
140
|
+
with file_lock(lock):
|
|
141
|
+
with pytest.raises(StorageError):
|
|
142
|
+
with file_lock(lock, timeout=.05):
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_parallel_cache_writers_preserve_valid_json(tmp_path):
|
|
147
|
+
root, _ = make_pi(tmp_path)
|
|
148
|
+
cache = tmp_path / "cache/index.json"
|
|
149
|
+
failures = []
|
|
150
|
+
def writer():
|
|
151
|
+
try:
|
|
152
|
+
for _ in range(4):
|
|
153
|
+
assert len(HistoryIndex([root], cache, "host").scan().records) == 1
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
failures.append(exc)
|
|
156
|
+
threads = [threading.Thread(target=writer) for _ in range(4)]
|
|
157
|
+
for thread in threads:
|
|
158
|
+
thread.start()
|
|
159
|
+
for thread in threads:
|
|
160
|
+
thread.join()
|
|
161
|
+
assert not failures
|
|
162
|
+
assert isinstance(read_json(cache), dict)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_cancelled_search_is_marked_partial(tmp_path):
|
|
166
|
+
root, _ = make_pi(tmp_path)
|
|
167
|
+
records = HistoryIndex([root], None, "host").scan().records
|
|
168
|
+
cancel = threading.Event()
|
|
169
|
+
cancel.set()
|
|
170
|
+
assert search_full(records, "中文", cancel).cancelled
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_fifo_source_is_rejected_without_blocking(tmp_path):
|
|
174
|
+
root, _ = make_pi(tmp_path)
|
|
175
|
+
fifo = Path(root.path) / "sessions/cwd/fifo.jsonl"
|
|
176
|
+
os.mkfifo(fifo)
|
|
177
|
+
with pytest.raises(PermissionError):
|
|
178
|
+
with open_source(str(fifo), root.path):
|
|
179
|
+
pass
|
|
180
|
+
result = HistoryIndex([root], None, "host").scan()
|
|
181
|
+
assert result.issues and len(result.records) == 1
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_source_not_written_and_no_network(tmp_path, monkeypatch):
|
|
185
|
+
import socket
|
|
186
|
+
root, source = make_pi(tmp_path)
|
|
187
|
+
before = source.read_bytes()
|
|
188
|
+
def forbidden(*args, **kwargs):
|
|
189
|
+
raise AssertionError("network access")
|
|
190
|
+
monkeypatch.setattr(socket, "socket", forbidden)
|
|
191
|
+
result = HistoryIndex([root], None, "host").scan()
|
|
192
|
+
search_full(result.records, "中文")
|
|
193
|
+
excerpt(result.records[0])
|
|
194
|
+
assert source.read_bytes() == before
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_cache_write_failure_does_not_hide_history(tmp_path, monkeypatch):
|
|
198
|
+
import session_ls.api as api
|
|
199
|
+
root, _ = make_pi(tmp_path)
|
|
200
|
+
def disk_full(*args, **kwargs):
|
|
201
|
+
raise OSError(28, "disk full")
|
|
202
|
+
monkeypatch.setattr(api, "atomic_json", disk_full)
|
|
203
|
+
result = HistoryIndex([root], tmp_path / "cache/data.json", "host").scan()
|
|
204
|
+
assert len(result.records) == 1
|
|
205
|
+
assert any("Cannot save" in issue for issue in result.issues)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_unreadable_child_directory_is_reported_not_silently_hidden(tmp_path, monkeypatch):
|
|
209
|
+
import session_ls.api as api
|
|
210
|
+
root, _ = make_pi(tmp_path)
|
|
211
|
+
(Path(root.path) / "sessions" / "denied").mkdir()
|
|
212
|
+
original = api.os.open
|
|
213
|
+
def guarded(path, *args, **kwargs):
|
|
214
|
+
if str(path) == "denied" and kwargs.get("dir_fd") is not None:
|
|
215
|
+
raise PermissionError("injected unreadable directory")
|
|
216
|
+
return original(path, *args, **kwargs)
|
|
217
|
+
monkeypatch.setattr(api.os, "open", guarded)
|
|
218
|
+
result = HistoryIndex([root]).scan()
|
|
219
|
+
assert len(result.records) == 1
|
|
220
|
+
assert any("denied" in issue and "PermissionError" in issue for issue in result.issues)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_cancellation_during_last_file_is_reported(tmp_path, monkeypatch):
|
|
224
|
+
import session_ls.api as api
|
|
225
|
+
root, _ = make_pi(tmp_path)
|
|
226
|
+
rows = HistoryIndex([root]).scan().records
|
|
227
|
+
stop = threading.Event()
|
|
228
|
+
def cancelled(*args):
|
|
229
|
+
stop.set()
|
|
230
|
+
return False, False
|
|
231
|
+
monkeypatch.setattr(api, "_search_stream", cancelled)
|
|
232
|
+
assert search_full(rows, "anything", stop).cancelled
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
CLAUDE_NATIVE = "11112222-3333-4444-5555-666677778888"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def make_claude(tmp_path, records):
|
|
239
|
+
root = tmp_path / "claude"
|
|
240
|
+
file = root / "projects/-tmp-project" / f"{CLAUDE_NATIVE}.jsonl"
|
|
241
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
file.write_text("\n".join(json.dumps(value) for value in records) + "\n")
|
|
243
|
+
return Root("claude", str(root)), file
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_claude_metadata_only_session_is_recognized(tmp_path):
|
|
247
|
+
# Claude 2.1.x prepends metadata records; a session that was opened and quit
|
|
248
|
+
# never writes a user/assistant message. It must still list, not error out.
|
|
249
|
+
root, file = make_claude(tmp_path, [
|
|
250
|
+
{"type": "last-prompt", "leafUuid": "c450dde6", "sessionId": CLAUDE_NATIVE},
|
|
251
|
+
{"type": "mode", "mode": "normal", "sessionId": CLAUDE_NATIVE},
|
|
252
|
+
{"type": "attachment", "cwd": str(tmp_path), "timestamp": "2026-09-23T03:25:31.930Z",
|
|
253
|
+
"sessionId": CLAUDE_NATIVE},
|
|
254
|
+
])
|
|
255
|
+
record, _ = read_metadata(root, str(file))
|
|
256
|
+
assert record.native_id == CLAUDE_NATIVE
|
|
257
|
+
assert record.cwd == str(tmp_path)
|
|
258
|
+
assert record.started == "2026-09-23T03:25:31.930000+00:00"
|
|
259
|
+
assert record.status == "partial" # no user text, but a real session
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_claude_native_title_is_the_fallback(tmp_path):
|
|
263
|
+
root, file = make_claude(tmp_path, [
|
|
264
|
+
{"type": "ai-title", "aiTitle": "review sandbox changes", "sessionId": CLAUDE_NATIVE},
|
|
265
|
+
{"type": "agent-name", "agentName": "reviewer", "sessionId": CLAUDE_NATIVE},
|
|
266
|
+
])
|
|
267
|
+
record, _ = read_metadata(root, str(file))
|
|
268
|
+
assert record.title == "review sandbox changes"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_claude_user_text_outranks_native_title(tmp_path):
|
|
272
|
+
root, file = make_claude(tmp_path, [
|
|
273
|
+
{"type": "ai-title", "aiTitle": "generated title", "sessionId": CLAUDE_NATIVE},
|
|
274
|
+
{"type": "user", "cwd": str(tmp_path), "timestamp": "2026-09-24T00:00:00Z",
|
|
275
|
+
"sessionId": CLAUDE_NATIVE, "message": {"role": "user", "content": "my own words"}},
|
|
276
|
+
])
|
|
277
|
+
record, _ = read_metadata(root, str(file))
|
|
278
|
+
assert record.title == "my own words"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_unrecognized_format_still_reports_the_reason(tmp_path):
|
|
282
|
+
root, file = make_claude(tmp_path, [{"type": "something-else", "payload": 1}])
|
|
283
|
+
with pytest.raises(ValueError, match="Unrecognized history format"):
|
|
284
|
+
read_metadata(root, str(file))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_scan_reports_no_issue_for_metadata_only_claude(tmp_path):
|
|
288
|
+
root, file = make_claude(tmp_path, [
|
|
289
|
+
{"type": "last-prompt", "leafUuid": "c450dde6", "sessionId": CLAUDE_NATIVE},
|
|
290
|
+
{"type": "attachment", "cwd": str(tmp_path), "sessionId": CLAUDE_NATIVE},
|
|
291
|
+
])
|
|
292
|
+
result = HistoryIndex([root], tmp_path / "cache/data.json", "host").scan()
|
|
293
|
+
assert [record.native_id for record in result.records] == [CLAUDE_NATIVE]
|
|
294
|
+
assert result.issues == []
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_scan_issue_names_the_reason_not_only_the_exception_type(tmp_path):
|
|
298
|
+
root, file = make_claude(tmp_path, [{"type": "something-else", "payload": 1}])
|
|
299
|
+
result = HistoryIndex([root], tmp_path / "cache/data.json", "host").scan()
|
|
300
|
+
assert result.records == []
|
|
301
|
+
assert result.issues and "ValueError" in result.issues[0]
|
|
302
|
+
assert "Unrecognized history format" in result.issues[0]
|
|
303
|
+
assert str(tmp_path) not in result.issues[0] # never leak the private path
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_a_file_matching_the_pattern_is_skipped_silently(tmp_path):
|
|
307
|
+
# Real stores contain marker files next to project directories. Matching one is
|
|
308
|
+
# not a degraded source, so it must not be reported on every scan.
|
|
309
|
+
root, file = make_pi(tmp_path)
|
|
310
|
+
(file.parent.parent.parent / "sessions" / "not-a-project").write_text("marker\n")
|
|
311
|
+
result = HistoryIndex([Root("pi", str(root.path))], tmp_path / "cache/data.json", "host").scan()
|
|
312
|
+
assert len(result.records) == 1
|
|
313
|
+
assert result.issues == []
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_a_root_that_is_not_a_directory_is_reported_once(tmp_path):
|
|
317
|
+
target = tmp_path / "not-a-dir"
|
|
318
|
+
target.write_text("marker\n")
|
|
319
|
+
result = HistoryIndex([Root("pi", str(target))], tmp_path / "cache/data.json", "host").scan()
|
|
320
|
+
assert result.records == []
|
|
321
|
+
assert result.issues == ["pi: configured root is not a directory"]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_unreadable_directory_is_still_reported(tmp_path, monkeypatch):
|
|
325
|
+
root, file = make_pi(tmp_path)
|
|
326
|
+
(file.parent.parent.parent / "sessions" / "closed").mkdir()
|
|
327
|
+
real_open = os.open
|
|
328
|
+
target = str(file.parent.parent.parent / "sessions" / "closed")
|
|
329
|
+
|
|
330
|
+
def refusing(path, *args, **kwargs):
|
|
331
|
+
if str(path) == "closed" or str(path) == target:
|
|
332
|
+
raise PermissionError(13, "Permission denied")
|
|
333
|
+
return real_open(path, *args, **kwargs)
|
|
334
|
+
|
|
335
|
+
monkeypatch.setattr(os, "open", refusing)
|
|
336
|
+
index = HistoryIndex([Root("pi", str(root.path))], tmp_path / "cache/data.json", "host")
|
|
337
|
+
result = index.scan()
|
|
338
|
+
assert any("PermissionError" in issue for issue in result.issues), result.issues
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""End-to-end CLI tests for session-ls: run `pytest tests/` or
|
|
3
3
|
`python3 -m pytest` (plain asserts, also runnable one file at a time)."""
|
|
4
|
-
import contextlib
|
|
5
|
-
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from datetime import datetime
|
|
6
11
|
|
|
7
12
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "src"))
|
|
8
13
|
import session_ls as s
|
|
9
14
|
|
|
15
|
+
|
|
10
16
|
def _pi_session(cwd, ts, msgs):
|
|
11
17
|
lines = [json.dumps({"type": "session", "id": "x", "timestamp": ts, "cwd": cwd})]
|
|
12
18
|
for i, (role, text) in enumerate(msgs):
|
|
@@ -102,7 +108,7 @@ def test_list_and_json_output():
|
|
|
102
108
|
reg = _pi_registry(d)
|
|
103
109
|
paths = _run(["-l", "web"], reg).splitlines()
|
|
104
110
|
assert len(paths) == 1 and paths[0].endswith("c.jsonl")
|
|
105
|
-
rows = [json.loads(
|
|
111
|
+
rows = [json.loads(line) for line in _run(["--json"], reg).splitlines()]
|
|
106
112
|
assert [r["title"] for r in rows] == ["deploy web app", "tune postgres pool", "fix login loop"]
|
|
107
113
|
assert set(rows[0]) == {"agent", "cwd", "started", "last", "title", "file"}
|
|
108
114
|
# --json + -l: --json wins (checked first in main())
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Parser checks for session-ls: run `pytest tests/` or `python3 -m pytest`."""
|
|
3
|
-
import json
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
4
7
|
|
|
5
8
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "src"))
|
|
6
9
|
import session_ls as s
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|