claude-opencode-sessions 0.1.0__py3-none-any.whl
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.
- claude_opencode_sessions/__init__.py +10 -0
- claude_opencode_sessions/__main__.py +3 -0
- claude_opencode_sessions/cli.py +196 -0
- claude_opencode_sessions/cli_backend.py +140 -0
- claude_opencode_sessions/errors.py +39 -0
- claude_opencode_sessions/import_command.py +216 -0
- claude_opencode_sessions/importer.py +414 -0
- claude_opencode_sessions/locate.py +90 -0
- claude_opencode_sessions/models.py +96 -0
- claude_opencode_sessions/parse.py +224 -0
- claude_opencode_sessions/py.typed +0 -0
- claude_opencode_sessions/render.py +169 -0
- claude_opencode_sessions/scope.py +160 -0
- claude_opencode_sessions/service.py +149 -0
- claude_opencode_sessions/sqlite_backend.py +249 -0
- claude_opencode_sessions/tests/__init__.py +0 -0
- claude_opencode_sessions/tests/conftest.py +314 -0
- claude_opencode_sessions/tests/fixtures/schema_1x.sql +76 -0
- claude_opencode_sessions/tests/fixtures/schema_2x.sql +37 -0
- claude_opencode_sessions/tests/test_cli.py +140 -0
- claude_opencode_sessions/tests/test_cli_backend.py +116 -0
- claude_opencode_sessions/tests/test_importer.py +504 -0
- claude_opencode_sessions/tests/test_launcher.py +47 -0
- claude_opencode_sessions/tests/test_locate.py +62 -0
- claude_opencode_sessions/tests/test_parse.py +120 -0
- claude_opencode_sessions/tests/test_render.py +107 -0
- claude_opencode_sessions/tests/test_scope.py +121 -0
- claude_opencode_sessions/tests/test_sqlite_backend.py +157 -0
- claude_opencode_sessions-0.1.0.dist-info/METADATA +287 -0
- claude_opencode_sessions-0.1.0.dist-info/RECORD +33 -0
- claude_opencode_sessions-0.1.0.dist-info/WHEEL +4 -0
- claude_opencode_sessions-0.1.0.dist-info/entry_points.txt +2 -0
- claude_opencode_sessions-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from claude_opencode_sessions.models import format_model
|
|
2
|
+
from claude_opencode_sessions.parse import (
|
|
3
|
+
loads,
|
|
4
|
+
parse_v1_message,
|
|
5
|
+
parse_v1_part,
|
|
6
|
+
parse_v2_row,
|
|
7
|
+
)
|
|
8
|
+
from claude_opencode_sessions.tests.conftest import text_of
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_format_model_variants():
|
|
12
|
+
assert format_model('{"id": "m", "providerID": "p"}') == "p/m"
|
|
13
|
+
assert format_model({"modelID": "m"}) == "m"
|
|
14
|
+
assert format_model("plain-model") == "plain-model"
|
|
15
|
+
assert format_model("{broken") == "{broken"
|
|
16
|
+
assert format_model(None) is None
|
|
17
|
+
assert format_model({"x": 1}) is None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_loads_is_forgiving():
|
|
21
|
+
assert loads('{"a": 1}') == {"a": 1}
|
|
22
|
+
assert loads("not json") is None
|
|
23
|
+
assert loads(None) is None
|
|
24
|
+
assert loads({"a": 1}) == {"a": 1}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_v1_parts():
|
|
28
|
+
assert parse_v1_part({"type": "step-start"}) is None
|
|
29
|
+
assert parse_v1_part({"type": "step-finish"}) is None
|
|
30
|
+
text = parse_v1_part({"type": "text", "text": "hi", "synthetic": True})
|
|
31
|
+
assert text is not None
|
|
32
|
+
assert (text.kind, text.text, text.synthetic) == ("text", "hi", True)
|
|
33
|
+
tool = parse_v1_part(
|
|
34
|
+
{
|
|
35
|
+
"type": "tool",
|
|
36
|
+
"tool": "bash",
|
|
37
|
+
"state": {"status": "error", "input": {"command": "ls"}, "error": "boom"},
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
assert tool is not None
|
|
41
|
+
assert (tool.tool, tool.status, tool.tool_output) == ("bash", "error", "boom")
|
|
42
|
+
assert tool.tool_input == {"command": "ls"}
|
|
43
|
+
patch = parse_v1_part({"type": "patch", "files": ["a.py", "b.py"]})
|
|
44
|
+
assert patch is not None and patch.files == ["a.py", "b.py"]
|
|
45
|
+
file_part = parse_v1_part({"type": "file", "filename": "x.png"})
|
|
46
|
+
assert file_part is not None and file_part.text == "x.png"
|
|
47
|
+
compaction = parse_v1_part({"type": "compaction"})
|
|
48
|
+
assert compaction is not None and compaction.kind == "compaction"
|
|
49
|
+
other = parse_v1_part({"type": "weird", "text": "?"})
|
|
50
|
+
assert other is not None and other.kind == "weird"
|
|
51
|
+
assert parse_v1_part("raw").kind == "other" # type: ignore[union-attr]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_v1_message_model_and_error():
|
|
55
|
+
message = parse_v1_message(
|
|
56
|
+
{
|
|
57
|
+
"id": "m1",
|
|
58
|
+
"role": "assistant",
|
|
59
|
+
"modelID": "gpt",
|
|
60
|
+
"providerID": "openai",
|
|
61
|
+
"error": {"name": "APIError", "data": {"message": "rate limited"}},
|
|
62
|
+
"time": {"created": 5},
|
|
63
|
+
"cost": 0.5,
|
|
64
|
+
},
|
|
65
|
+
[{"type": "text", "text": "a"}, {"type": "step-finish"}],
|
|
66
|
+
seq=3,
|
|
67
|
+
)
|
|
68
|
+
assert message.model == "openai/gpt"
|
|
69
|
+
assert message.error == "APIError: rate limited"
|
|
70
|
+
assert message.seq == 3
|
|
71
|
+
assert message.time_created == 5
|
|
72
|
+
assert message.cost == 0.5
|
|
73
|
+
assert [p.kind for p in message.parts] == ["text"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_v1_message_user_model_object():
|
|
77
|
+
message = parse_v1_message(
|
|
78
|
+
{"role": "user", "model": {"providerID": "p", "modelID": "m"}}, []
|
|
79
|
+
)
|
|
80
|
+
assert message.model == "p/m"
|
|
81
|
+
assert message.role == "user"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_v2_user_row():
|
|
85
|
+
message, ok = parse_v2_row("r1", "user", '{"text": "hello"}', 1, 10)
|
|
86
|
+
assert ok
|
|
87
|
+
assert message.role == "user"
|
|
88
|
+
assert text_of(message) == "hello"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_v2_assistant_row_with_content_list():
|
|
92
|
+
data = {
|
|
93
|
+
"model": {"id": "m", "providerID": "p"},
|
|
94
|
+
"tokens": {"input": 1, "output": 2},
|
|
95
|
+
"cost": 0.1,
|
|
96
|
+
"content": [
|
|
97
|
+
{"type": "text", "text": "answer"},
|
|
98
|
+
{"type": "reasoning", "text": "hmm"},
|
|
99
|
+
{"type": "tool", "tool": "read", "state": {"input": {"filePath": "a"}}},
|
|
100
|
+
{"type": "tool-call", "name": "bash", "args": {"command": "ls"}},
|
|
101
|
+
"loose string",
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
message, ok = parse_v2_row("r2", "assistant", data, 2, 11)
|
|
105
|
+
assert ok
|
|
106
|
+
assert message.model == "p/m"
|
|
107
|
+
kinds = [p.kind for p in message.parts]
|
|
108
|
+
assert kinds == ["text", "reasoning", "tool", "tool", "text"]
|
|
109
|
+
assert message.parts[3].tool == "bash"
|
|
110
|
+
assert message.parts[3].tool_input == {"command": "ls"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_v2_tool_row_and_bad_json():
|
|
114
|
+
message, ok = parse_v2_row("r3", "tool", {"tool": "grep", "input": {}}, 3, 12)
|
|
115
|
+
assert ok and message.parts[0].tool == "grep"
|
|
116
|
+
broken, ok = parse_v2_row("r4", "assistant", "{nope", 4, 13)
|
|
117
|
+
assert not ok
|
|
118
|
+
assert broken.parts[-1].text == "[unparseable message payload]"
|
|
119
|
+
idle, ok = parse_v2_row("r5", "idle", "{}", 5, 14)
|
|
120
|
+
assert ok and idle.role == "idle" and idle.parts == []
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from claude_opencode_sessions.models import Part, Session
|
|
2
|
+
from claude_opencode_sessions.render import (
|
|
3
|
+
absolute_time,
|
|
4
|
+
cell,
|
|
5
|
+
relative_time,
|
|
6
|
+
render_list,
|
|
7
|
+
tilde,
|
|
8
|
+
tool_summary,
|
|
9
|
+
truncate,
|
|
10
|
+
)
|
|
11
|
+
from claude_opencode_sessions.scope import Scope
|
|
12
|
+
from claude_opencode_sessions.tests.conftest import HOUR, NOW
|
|
13
|
+
|
|
14
|
+
SCOPE = Scope("worktree", "/repo", ["/repo"], in_git=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def session(**kw) -> Session:
|
|
18
|
+
base = {
|
|
19
|
+
"id": "ses_1",
|
|
20
|
+
"title": "Fix | the bug",
|
|
21
|
+
"directory": "/repo/sub",
|
|
22
|
+
"time_created": NOW - 2 * HOUR,
|
|
23
|
+
"time_updated": NOW - HOUR,
|
|
24
|
+
"agent": "build",
|
|
25
|
+
"model": "openai/gpt",
|
|
26
|
+
"message_count": 4,
|
|
27
|
+
}
|
|
28
|
+
base.update(kw)
|
|
29
|
+
return Session(**base)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_relative_and_absolute_time():
|
|
33
|
+
assert relative_time(None) == "?"
|
|
34
|
+
assert relative_time(NOW - 10_000, NOW) == "just now"
|
|
35
|
+
assert relative_time(NOW - 5 * 60_000, NOW) == "5m ago"
|
|
36
|
+
assert relative_time(NOW - 3 * HOUR, NOW) == "3h ago"
|
|
37
|
+
assert relative_time(NOW - 50 * HOUR, NOW) == "2d ago"
|
|
38
|
+
assert relative_time(NOW - 40 * 24 * HOUR, NOW).count("-") == 2
|
|
39
|
+
assert absolute_time(None) == "?"
|
|
40
|
+
assert len(absolute_time(NOW)) == len("2026-09-21 20:51")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_truncate_cell_and_tilde(monkeypatch):
|
|
44
|
+
assert truncate("abc", 10) == "abc"
|
|
45
|
+
assert truncate("abcdef", 3).startswith("abc\n… [3 more chars]")
|
|
46
|
+
assert truncate("abcdef", 0) == "abcdef"
|
|
47
|
+
assert cell("a | b\nc") == "a \\| b c"
|
|
48
|
+
assert cell("x" * 100, 10) == "x" * 9 + "…"
|
|
49
|
+
monkeypatch.setenv("HOME", "/home/me")
|
|
50
|
+
assert tilde("/home/me/repo") == "~/repo"
|
|
51
|
+
assert tilde("/other") == "/other"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_tool_summary():
|
|
55
|
+
assert tool_summary(
|
|
56
|
+
Part("tool", tool="read", tool_input={"filePath": "/a.py"})
|
|
57
|
+
) == ("→ read: `/a.py`")
|
|
58
|
+
multi = tool_summary(Part("tool", tool="bash", tool_input={"command": "a\nb"}))
|
|
59
|
+
assert multi == "→ bash: `a …`"
|
|
60
|
+
failed = Part("tool", tool="bash", tool_input={"command": "x"}, status="error")
|
|
61
|
+
assert tool_summary(failed).endswith("(error)")
|
|
62
|
+
assert tool_summary(Part("tool", tool="todo", tool_input={"k": 1})).startswith(
|
|
63
|
+
'→ todo: `{"k": 1}'
|
|
64
|
+
)
|
|
65
|
+
assert tool_summary(Part("tool", tool="noargs", title="T")) == "→ noargs: `T`"
|
|
66
|
+
assert tool_summary(Part("tool", tool="bare")) == "→ bare"
|
|
67
|
+
assert tool_summary(Part("tool", tool="s", tool_input="raw\nmore")) == "→ s: `raw`"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_render_list_table_and_footer():
|
|
71
|
+
out = render_list(
|
|
72
|
+
[
|
|
73
|
+
session(),
|
|
74
|
+
session(id="ses_2", directory="/repo", parent_id="x", time_archived=1),
|
|
75
|
+
],
|
|
76
|
+
SCOPE,
|
|
77
|
+
hidden_archived=2,
|
|
78
|
+
hidden_subagents=1,
|
|
79
|
+
total=5,
|
|
80
|
+
backend="sqlite",
|
|
81
|
+
notes=["hello"],
|
|
82
|
+
now_ms=NOW,
|
|
83
|
+
)
|
|
84
|
+
assert (
|
|
85
|
+
"| 1 | Fix \\| the bug | 1h ago | 4 | build · openai/gpt | sub | `ses_1` |"
|
|
86
|
+
in out
|
|
87
|
+
)
|
|
88
|
+
assert "_(archived, sub-agent)_" in out
|
|
89
|
+
assert "| . |" in out
|
|
90
|
+
assert "showing 2 of 5" in out
|
|
91
|
+
assert "2 archived (--include-archived)" in out
|
|
92
|
+
assert "1 sub-agent (--include-subagents)" in out
|
|
93
|
+
assert "_note: hello_" in out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_render_list_empty_suggests_wider_scope():
|
|
97
|
+
out = render_list([], SCOPE)
|
|
98
|
+
assert "No opencode sessions found" in out
|
|
99
|
+
assert "--worktrees" in out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_render_list_other_worktree_and_all_scope():
|
|
103
|
+
repo_scope = Scope("repo", "/repo", ["/repo", "/repo-feature"], in_git=True)
|
|
104
|
+
out = render_list([session(directory="/repo-feature/x")], repo_scope, now_ms=NOW)
|
|
105
|
+
assert "/repo-feature/x" in out
|
|
106
|
+
out = render_list([session()], Scope("all", "/repo", ["/repo"]), now_ms=NOW)
|
|
107
|
+
assert "/repo/sub" in out
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from claude_opencode_sessions.models import Session
|
|
8
|
+
from claude_opencode_sessions.scope import (
|
|
9
|
+
Scope,
|
|
10
|
+
git_toplevel,
|
|
11
|
+
git_worktrees,
|
|
12
|
+
is_within,
|
|
13
|
+
normalize,
|
|
14
|
+
path_key,
|
|
15
|
+
resolve_scope,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def s(sid: str, directory: str, project: str = "p") -> Session:
|
|
20
|
+
return Session(id=sid, title=sid, directory=directory, project_id=project)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_normalize_repairs_missing_leading_slash(tmp_path: Path):
|
|
24
|
+
assert normalize("Users/me/repo/") == os.path.realpath("/Users/me/repo")
|
|
25
|
+
assert normalize("") == ""
|
|
26
|
+
assert normalize(str(tmp_path) + "/") == str(tmp_path.resolve())
|
|
27
|
+
assert normalize("/") == "/"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_normalize_resolves_symlinks(tmp_path: Path):
|
|
31
|
+
target = tmp_path / "target"
|
|
32
|
+
target.mkdir()
|
|
33
|
+
link = tmp_path / "link"
|
|
34
|
+
link.symlink_to(target)
|
|
35
|
+
assert normalize(str(link)) == str(target.resolve())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_is_within():
|
|
39
|
+
assert is_within("/a/b", "/a/b")
|
|
40
|
+
assert is_within("/a/b/c", "/a/b")
|
|
41
|
+
assert not is_within("/a/bc", "/a/b")
|
|
42
|
+
assert is_within("/anything", "/")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS is case-insensitive")
|
|
46
|
+
def test_path_key_casefolds_on_macos(): # pragma: no cover - platform specific
|
|
47
|
+
assert path_key("/Users/Me") == path_key("/users/me")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_invalid_mode():
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
resolve_scope("/", "nope")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_non_git_directory_scope(git_repo):
|
|
56
|
+
plain = git_repo["plain"]
|
|
57
|
+
scope = resolve_scope(str(plain / "inner"))
|
|
58
|
+
assert not scope.in_git
|
|
59
|
+
assert scope.roots == [str(plain / "inner")]
|
|
60
|
+
sessions = [
|
|
61
|
+
s("in", str(plain / "inner")),
|
|
62
|
+
s("below", str(plain / "inner" / "x")),
|
|
63
|
+
s("parent", str(plain)),
|
|
64
|
+
]
|
|
65
|
+
assert [x.id for x in scope.filter(sessions)] == ["in", "below"]
|
|
66
|
+
assert "directory" in scope.describe()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_worktree_scope_uses_git_toplevel(git_repo):
|
|
70
|
+
repo, feature = git_repo["repo"], git_repo["feature"]
|
|
71
|
+
scope = resolve_scope(str(repo / "sub" / "deep"))
|
|
72
|
+
assert scope.in_git
|
|
73
|
+
assert scope.root == str(repo)
|
|
74
|
+
sessions = [
|
|
75
|
+
s("root", str(repo)),
|
|
76
|
+
s("sub", str(repo / "sub")),
|
|
77
|
+
s("feature", str(feature)),
|
|
78
|
+
s("other", "/somewhere/else"),
|
|
79
|
+
]
|
|
80
|
+
assert [x.id for x in scope.filter(sessions)] == ["root", "sub"]
|
|
81
|
+
assert "git worktree" in scope.describe()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_repo_scope_includes_all_worktrees(git_repo):
|
|
85
|
+
repo, feature = git_repo["repo"], git_repo["feature"]
|
|
86
|
+
assert git_toplevel(str(feature)) == str(feature)
|
|
87
|
+
assert set(git_worktrees(str(repo))) == {str(repo), str(feature)}
|
|
88
|
+
scope = resolve_scope(str(feature), "repo")
|
|
89
|
+
assert scope.root == str(feature) # current worktree first
|
|
90
|
+
assert set(scope.roots) == {str(repo), str(feature)}
|
|
91
|
+
sessions = [
|
|
92
|
+
s("root", str(repo)),
|
|
93
|
+
s("feature", str(feature / "x")),
|
|
94
|
+
s("gone", str(repo.parent / "deleted-worktree")), # same project, missing
|
|
95
|
+
s("gone-other-project", str(repo.parent / "deleted2"), project="q"),
|
|
96
|
+
s("global", str(repo.parent / "deleted3"), project="global"),
|
|
97
|
+
]
|
|
98
|
+
assert [x.id for x in scope.filter(sessions)] == ["root", "feature", "gone"]
|
|
99
|
+
assert "worktree(s)" in scope.describe()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_repo_scope_outside_git_behaves_like_worktree(git_repo):
|
|
103
|
+
scope = resolve_scope(str(git_repo["plain"]), "repo")
|
|
104
|
+
assert scope.roots == [str(git_repo["plain"])]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_all_scope_matches_everything(git_repo):
|
|
108
|
+
scope = resolve_scope(str(git_repo["repo"]), "all")
|
|
109
|
+
assert scope.matches("/whatever")
|
|
110
|
+
assert len(scope.filter([s("a", "/x"), s("b", "/y")])) == 2
|
|
111
|
+
assert scope.describe() == "all sessions on this machine"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_git_helpers_outside_repo(tmp_path: Path):
|
|
115
|
+
assert git_toplevel(str(tmp_path / "missing")) is None
|
|
116
|
+
assert git_worktrees(str(tmp_path / "missing")) == []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_scope_root_defaults_to_cwd():
|
|
120
|
+
scope = Scope("worktree", "/x")
|
|
121
|
+
assert scope.root == "/x"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from claude_opencode_sessions.errors import SchemaError
|
|
7
|
+
from claude_opencode_sessions.sqlite_backend import SqliteBackend
|
|
8
|
+
from claude_opencode_sessions.tests.conftest import (
|
|
9
|
+
NOW,
|
|
10
|
+
DbBuilder,
|
|
11
|
+
assistant,
|
|
12
|
+
text,
|
|
13
|
+
text_of,
|
|
14
|
+
tool,
|
|
15
|
+
user,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_lists_1x_sessions(db_1x: DbBuilder):
|
|
20
|
+
db_1x.session_1x(
|
|
21
|
+
"ses_a",
|
|
22
|
+
"/work/repo",
|
|
23
|
+
title="First",
|
|
24
|
+
messages=[user("hi"), assistant(text("hello"))],
|
|
25
|
+
)
|
|
26
|
+
db_1x.session_1x("ses_b", "/work/repo/sub", updated=NOW - 10, parent_id="ses_a")
|
|
27
|
+
db_1x.close()
|
|
28
|
+
backend = SqliteBackend(db_1x.path)
|
|
29
|
+
sessions = {s.id: s for s in backend.list_sessions()}
|
|
30
|
+
assert set(sessions) == {"ses_a", "ses_b"}
|
|
31
|
+
first = sessions["ses_a"]
|
|
32
|
+
assert first.title == "First"
|
|
33
|
+
assert first.message_count == 2
|
|
34
|
+
assert first.model == "openai/gpt-x"
|
|
35
|
+
assert first.agent == "build"
|
|
36
|
+
assert first.version == "1.18.32"
|
|
37
|
+
assert first.origin == "session"
|
|
38
|
+
assert sessions["ses_b"].is_subagent
|
|
39
|
+
assert "1.x message/part" in backend.describe()
|
|
40
|
+
assert backend.table_report()["message"] == 2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_loads_1x_messages_in_order(db_1x: DbBuilder):
|
|
44
|
+
db_1x.session_1x(
|
|
45
|
+
"ses_a",
|
|
46
|
+
"/w",
|
|
47
|
+
messages=[
|
|
48
|
+
user("question"),
|
|
49
|
+
assistant(text("thinking out loud"), tool("bash", {"command": "ls"})),
|
|
50
|
+
assistant(text("done")),
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
db_1x.close()
|
|
54
|
+
messages, errors = SqliteBackend(db_1x.path).load_messages("ses_a")
|
|
55
|
+
assert errors == 0
|
|
56
|
+
assert [m.role for m in messages] == ["user", "assistant", "assistant"]
|
|
57
|
+
assert [m.seq for m in messages] == [1, 2, 3]
|
|
58
|
+
assert text_of(messages[0]) == "question"
|
|
59
|
+
assert [p.kind for p in messages[1].parts] == ["text", "tool"]
|
|
60
|
+
assert messages[1].model == "openai/gpt-x"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_counts_parse_errors(db_1x: DbBuilder):
|
|
64
|
+
db_1x.session_1x("ses_a", "/w", messages=[({"role": "user"}, ["{broken"])])
|
|
65
|
+
db_1x.close()
|
|
66
|
+
messages, errors = SqliteBackend(db_1x.path).load_messages("ses_a")
|
|
67
|
+
assert errors == 1
|
|
68
|
+
assert len(messages) == 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_2x_sessions_and_messages(db_2x: DbBuilder):
|
|
72
|
+
db_2x.session_2x(
|
|
73
|
+
"ses_v2",
|
|
74
|
+
"/w",
|
|
75
|
+
rows=[
|
|
76
|
+
("user", {"text": "hi"}),
|
|
77
|
+
("assistant", {"content": [{"type": "text", "text": "yo"}]}),
|
|
78
|
+
("idle", {}),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
db_2x.close()
|
|
82
|
+
backend = SqliteBackend(db_2x.path)
|
|
83
|
+
[session] = backend.list_sessions()
|
|
84
|
+
assert session.origin == "session_v2"
|
|
85
|
+
assert session.message_count == 2 # only user/assistant rows count
|
|
86
|
+
assert session.version is None # column missing in 2.x fixture
|
|
87
|
+
messages, errors = backend.load_messages("ses_v2")
|
|
88
|
+
assert errors == 0
|
|
89
|
+
assert [m.role for m in messages] == ["user", "assistant", "idle"]
|
|
90
|
+
assert "2.x session_message" in backend.describe()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_mixed_database_prefers_2x_and_falls_back_per_session(tmp_path: Path):
|
|
94
|
+
builder = DbBuilder(tmp_path / "mixed.db", "schema_1x.sql")
|
|
95
|
+
builder.conn.executescript(
|
|
96
|
+
(Path(__file__).parent / "fixtures" / "schema_2x.sql")
|
|
97
|
+
.read_text()
|
|
98
|
+
.replace("CREATE TABLE `project`", "CREATE TABLE `project_unused`")
|
|
99
|
+
.replace(
|
|
100
|
+
"CREATE TABLE `session_message`", "CREATE TABLE `session_message_unused`"
|
|
101
|
+
)
|
|
102
|
+
.replace("ON `session_message`", "ON `session_message_unused`")
|
|
103
|
+
.replace("session_message_session_seq_idx", "unused_idx")
|
|
104
|
+
)
|
|
105
|
+
# Migrated session: exists in both tables, 2.x metadata wins.
|
|
106
|
+
builder.session_1x("ses_same", "/w", title="old title", messages=[user("legacy")])
|
|
107
|
+
builder.session_2x("ses_same", "/w", title="new title")
|
|
108
|
+
# Session only in the frozen 1.x table.
|
|
109
|
+
builder.session_1x("ses_old", "/w", title="only legacy")
|
|
110
|
+
# Session with 2.x messages.
|
|
111
|
+
builder.session_2x("ses_new", "/w", rows=[("user", {"text": "fresh"})])
|
|
112
|
+
builder.close()
|
|
113
|
+
backend = SqliteBackend(builder.path)
|
|
114
|
+
sessions = {s.id: s for s in backend.list_sessions()}
|
|
115
|
+
assert set(sessions) == {"ses_same", "ses_old", "ses_new"}
|
|
116
|
+
assert sessions["ses_same"].title == "new title"
|
|
117
|
+
assert sessions["ses_same"].origin == "session_v2"
|
|
118
|
+
assert sessions["ses_old"].origin == "session"
|
|
119
|
+
# No 2.x rows for ses_same -> falls back to 1.x messages.
|
|
120
|
+
messages, _ = backend.load_messages("ses_same")
|
|
121
|
+
assert text_of(messages[0]) == "legacy"
|
|
122
|
+
messages, _ = backend.load_messages("ses_new")
|
|
123
|
+
assert text_of(messages[0]) == "fresh"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_rejects_unrelated_database(tmp_path: Path):
|
|
127
|
+
path = tmp_path / "other.db"
|
|
128
|
+
conn = sqlite3.connect(path)
|
|
129
|
+
conn.execute("CREATE TABLE foo (id)")
|
|
130
|
+
conn.close()
|
|
131
|
+
with pytest.raises(SchemaError, match="no opencode session table"):
|
|
132
|
+
SqliteBackend(path)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_rejects_session_table_without_messages(tmp_path: Path):
|
|
136
|
+
path = tmp_path / "half.db"
|
|
137
|
+
conn = sqlite3.connect(path)
|
|
138
|
+
conn.execute("CREATE TABLE session (id, directory, time_updated)")
|
|
139
|
+
conn.close()
|
|
140
|
+
with pytest.raises(SchemaError, match="no opencode message tables"):
|
|
141
|
+
SqliteBackend(path)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_rejects_non_sqlite_file(tmp_path: Path):
|
|
145
|
+
path = tmp_path / "garbage.db"
|
|
146
|
+
path.write_bytes(b"this is not a database" * 100)
|
|
147
|
+
with pytest.raises(SchemaError):
|
|
148
|
+
SqliteBackend(path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_never_writes(db_1x: DbBuilder):
|
|
152
|
+
db_1x.session_1x("ses_a", "/w")
|
|
153
|
+
db_1x.close()
|
|
154
|
+
backend = SqliteBackend(db_1x.path)
|
|
155
|
+
with pytest.raises(sqlite3.OperationalError):
|
|
156
|
+
backend.conn.execute("DELETE FROM session")
|
|
157
|
+
backend.close()
|