agent-chat-reader 0.1.2__tar.gz → 0.1.4__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.
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/PKG-INFO +13 -1
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/README.md +12 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/pyproject.toml +1 -1
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/__init__.py +1 -1
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/cli.py +93 -11
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/output.py +62 -6
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/tests/test_package.py +88 -1
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/uv.lock +1 -1
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.github/workflows/ci.yml +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.github/workflows/release.yml +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.gitignore +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.pre-commit-config.yaml +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/.python-version +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/AGENTS.md +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/LICENSE +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/docs/api.md +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/docs/ci-private-submodules.md +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/claude.py +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/codex.py +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/models.py +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/src/agent_chat_reader/py.typed +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/templates/licenses/Apache-2.0 +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/templates/licenses/MIT +0 -0
- {agent_chat_reader-0.1.2 → agent_chat_reader-0.1.4}/templates/licenses/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-chat-reader
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Read and search Codex CLI and Claude Code chat history from the terminal.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alik-git/agent-chat-reader
|
|
6
6
|
Project-URL: Repository, https://github.com/alik-git/agent-chat-reader
|
|
@@ -68,6 +68,8 @@ agent-chat-reader --find "policy_interface" --source codex
|
|
|
68
68
|
agent-chat-reader 019eaecb
|
|
69
69
|
agent-chat-reader 1bfc739b --verbose # include tool call summaries
|
|
70
70
|
agent-chat-reader 019eaecb --tail 5 # last 5 user turns only
|
|
71
|
+
agent-chat-reader 019eaecb --hide-timestamps
|
|
72
|
+
agent-chat-reader 019eaecb --format json
|
|
71
73
|
```
|
|
72
74
|
|
|
73
75
|
## What it filters out
|
|
@@ -89,8 +91,18 @@ Use `--include-subagents` to see everything.
|
|
|
89
91
|
| `--verbose` / `-v` | Include brief tool call summaries (Claude sessions) |
|
|
90
92
|
| `--tail N` / `-n N` | Show only the last N user turns of a session |
|
|
91
93
|
| `--include-subagents` | Include guardian/subagent sessions |
|
|
94
|
+
| `--format text\|json` | Output format for session reads |
|
|
95
|
+
| `--hide-timestamps` | Hide message timestamps and elapsed-gap labels |
|
|
92
96
|
| `--limit N` | Max sessions shown by `--list` (default: 40) |
|
|
93
97
|
|
|
98
|
+
Session reads show timestamps by default. Elapsed labels use the current
|
|
99
|
+
speaker's role, such as `(agent took 8s)` or `(user took 4m)`, and only report
|
|
100
|
+
the time since the previous visible message.
|
|
101
|
+
|
|
102
|
+
Use `--format json` when another agent or script needs stable structured
|
|
103
|
+
fields instead of terminal separators. JSON turns include `timestamp`,
|
|
104
|
+
`local_time`, `elapsed_seconds`, `role`, and `text`.
|
|
105
|
+
|
|
94
106
|
## Session storage locations
|
|
95
107
|
|
|
96
108
|
| Agent | Path |
|
|
@@ -40,6 +40,8 @@ agent-chat-reader --find "policy_interface" --source codex
|
|
|
40
40
|
agent-chat-reader 019eaecb
|
|
41
41
|
agent-chat-reader 1bfc739b --verbose # include tool call summaries
|
|
42
42
|
agent-chat-reader 019eaecb --tail 5 # last 5 user turns only
|
|
43
|
+
agent-chat-reader 019eaecb --hide-timestamps
|
|
44
|
+
agent-chat-reader 019eaecb --format json
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
## What it filters out
|
|
@@ -61,8 +63,18 @@ Use `--include-subagents` to see everything.
|
|
|
61
63
|
| `--verbose` / `-v` | Include brief tool call summaries (Claude sessions) |
|
|
62
64
|
| `--tail N` / `-n N` | Show only the last N user turns of a session |
|
|
63
65
|
| `--include-subagents` | Include guardian/subagent sessions |
|
|
66
|
+
| `--format text\|json` | Output format for session reads |
|
|
67
|
+
| `--hide-timestamps` | Hide message timestamps and elapsed-gap labels |
|
|
64
68
|
| `--limit N` | Max sessions shown by `--list` (default: 40) |
|
|
65
69
|
|
|
70
|
+
Session reads show timestamps by default. Elapsed labels use the current
|
|
71
|
+
speaker's role, such as `(agent took 8s)` or `(user took 4m)`, and only report
|
|
72
|
+
the time since the previous visible message.
|
|
73
|
+
|
|
74
|
+
Use `--format json` when another agent or script needs stable structured
|
|
75
|
+
fields instead of terminal separators. JSON turns include `timestamp`,
|
|
76
|
+
`local_time`, `elapsed_seconds`, `role`, and `text`.
|
|
77
|
+
|
|
66
78
|
## Session storage locations
|
|
67
79
|
|
|
68
80
|
| Agent | Path |
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import json
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from agent_chat_reader import __version__, claude, codex
|
|
10
|
-
from agent_chat_reader.models import SessionMeta
|
|
11
|
+
from agent_chat_reader.models import SessionMeta, Turn
|
|
11
12
|
from agent_chat_reader.output import (
|
|
12
13
|
FindHit,
|
|
14
|
+
elapsed_seconds_between,
|
|
15
|
+
fmt_ts,
|
|
13
16
|
print_find_result,
|
|
14
17
|
print_session_list,
|
|
15
18
|
print_turn,
|
|
@@ -58,12 +61,12 @@ def _title_key(title: str) -> str:
|
|
|
58
61
|
|
|
59
62
|
|
|
60
63
|
def cmd_find(
|
|
61
|
-
|
|
64
|
+
keywords: list[str],
|
|
62
65
|
*,
|
|
63
66
|
source_filter: str | None,
|
|
64
67
|
include_subagents: bool,
|
|
65
68
|
) -> int:
|
|
66
|
-
"""Search sessions for
|
|
69
|
+
"""Search sessions for keywords (AND logic), deduplicating continuation sessions."""
|
|
67
70
|
sessions: list[SessionMeta] = []
|
|
68
71
|
if source_filter != "claude":
|
|
69
72
|
sessions += codex.list_sessions(include_subagents=include_subagents)
|
|
@@ -71,7 +74,12 @@ def cmd_find(
|
|
|
71
74
|
sessions += claude.list_sessions()
|
|
72
75
|
|
|
73
76
|
sessions = sorted(sessions, key=lambda s: s.mtime, reverse=True)
|
|
74
|
-
|
|
77
|
+
keywords_lower = [k.lower() for k in keywords]
|
|
78
|
+
|
|
79
|
+
def _turn_matches(text: str) -> bool:
|
|
80
|
+
"""Return True if the turn contains all keywords (AND)."""
|
|
81
|
+
text_lower = text.lower()
|
|
82
|
+
return all(k in text_lower for k in keywords_lower)
|
|
75
83
|
|
|
76
84
|
# Collect hits per session, then group by title to deduplicate continuations.
|
|
77
85
|
# Each group keeps the most-recent session's metadata as the representative.
|
|
@@ -89,7 +97,7 @@ def cmd_find(
|
|
|
89
97
|
hits: list[FindHit] = [
|
|
90
98
|
(t.role, t.text[:120].replace("\n", " "), t.timestamp)
|
|
91
99
|
for t in turns
|
|
92
|
-
if
|
|
100
|
+
if _turn_matches(t.text)
|
|
93
101
|
]
|
|
94
102
|
if not hits:
|
|
95
103
|
continue
|
|
@@ -99,12 +107,12 @@ def cmd_find(
|
|
|
99
107
|
groups[key] = (s, hits, 1)
|
|
100
108
|
else:
|
|
101
109
|
rep, existing_hits, count = groups[key]
|
|
102
|
-
# Merge hits, keeping the most-recent session as representative.
|
|
103
110
|
rep = s if s.mtime > rep.mtime else rep
|
|
104
111
|
groups[key] = (rep, existing_hits + hits, count + 1)
|
|
105
112
|
|
|
106
113
|
if not groups:
|
|
107
|
-
|
|
114
|
+
query = " AND ".join(f"{k!r}" for k in keywords)
|
|
115
|
+
print(f"No sessions found containing {query}")
|
|
108
116
|
return 0
|
|
109
117
|
|
|
110
118
|
for rep, hits, count in groups.values():
|
|
@@ -119,6 +127,8 @@ def cmd_read(
|
|
|
119
127
|
verbose: bool,
|
|
120
128
|
include_subagents: bool,
|
|
121
129
|
tail: int | None,
|
|
130
|
+
show_timestamps: bool,
|
|
131
|
+
output_format: str,
|
|
122
132
|
) -> int:
|
|
123
133
|
"""Read a specific session as clean conversation."""
|
|
124
134
|
result = _find_session(session_id)
|
|
@@ -128,7 +138,6 @@ def cmd_read(
|
|
|
128
138
|
|
|
129
139
|
path, source = result
|
|
130
140
|
size_kb = path.stat().st_size // 1024
|
|
131
|
-
print(f"Source: {source.upper()} | {path.name} | {size_kb}KB")
|
|
132
141
|
|
|
133
142
|
if source == "codex":
|
|
134
143
|
turns = codex.read_turns(path, tail=tail)
|
|
@@ -137,18 +146,69 @@ def cmd_read(
|
|
|
137
146
|
path, verbose=verbose, include_subagents=include_subagents, tail=tail
|
|
138
147
|
)
|
|
139
148
|
|
|
149
|
+
if output_format == "json":
|
|
150
|
+
print(_json_session(source=source, path=path, size_kb=size_kb, turns=turns))
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
print(f"Source: {source.upper()} | {path.name} | {size_kb}KB")
|
|
154
|
+
|
|
140
155
|
if not turns:
|
|
141
156
|
print("(no conversation turns found)")
|
|
142
157
|
return 0
|
|
143
158
|
|
|
159
|
+
previous_turn = None
|
|
144
160
|
for turn in turns:
|
|
145
|
-
print_turn(
|
|
161
|
+
print_turn(
|
|
162
|
+
turn,
|
|
163
|
+
show_timestamps=show_timestamps,
|
|
164
|
+
previous_turn=previous_turn,
|
|
165
|
+
)
|
|
166
|
+
previous_turn = turn
|
|
146
167
|
|
|
147
168
|
print(f"\n{'─' * 60}")
|
|
148
169
|
print(f"Total turns: {len(turns)}")
|
|
149
170
|
return 0
|
|
150
171
|
|
|
151
172
|
|
|
173
|
+
def _json_session(*, source: str, path: Path, size_kb: int, turns: list[Turn]) -> str:
|
|
174
|
+
"""Serialize a read session as structured JSON."""
|
|
175
|
+
previous_turn = None
|
|
176
|
+
turn_records: list[dict[str, object]] = []
|
|
177
|
+
for turn in turns:
|
|
178
|
+
elapsed_seconds: int | None = (
|
|
179
|
+
None
|
|
180
|
+
if previous_turn is None
|
|
181
|
+
else elapsed_seconds_between(previous_turn, turn)
|
|
182
|
+
)
|
|
183
|
+
turn_records.append(
|
|
184
|
+
{
|
|
185
|
+
"role": turn.role,
|
|
186
|
+
"timestamp": turn.timestamp or None,
|
|
187
|
+
"local_time": fmt_ts(turn.timestamp) or None,
|
|
188
|
+
"elapsed_seconds": elapsed_seconds,
|
|
189
|
+
"text": turn.text,
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
previous_turn = turn
|
|
193
|
+
|
|
194
|
+
payload = {
|
|
195
|
+
"source": source,
|
|
196
|
+
"session_id": _session_id_for_path(path, source),
|
|
197
|
+
"path": str(path),
|
|
198
|
+
"size_kb": size_kb,
|
|
199
|
+
"total_turns": len(turns),
|
|
200
|
+
"turns": turn_records,
|
|
201
|
+
}
|
|
202
|
+
return json.dumps(payload, indent=2)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _session_id_for_path(path: Path, source: str) -> str:
|
|
206
|
+
"""Return the source-specific session id for a session file."""
|
|
207
|
+
if source == "codex":
|
|
208
|
+
return codex._session_id_from_path(path)
|
|
209
|
+
return path.stem
|
|
210
|
+
|
|
211
|
+
|
|
152
212
|
def main(argv: list[str] | None = None) -> int:
|
|
153
213
|
"""Run the agent-chat-reader command-line interface."""
|
|
154
214
|
p = argparse.ArgumentParser(
|
|
@@ -165,8 +225,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
165
225
|
p.add_argument(
|
|
166
226
|
"--find",
|
|
167
227
|
"-f",
|
|
228
|
+
nargs="+",
|
|
168
229
|
metavar="KEYWORD",
|
|
169
|
-
help="Search sessions
|
|
230
|
+
help="Search sessions (multiple keywords = AND logic)",
|
|
170
231
|
)
|
|
171
232
|
p.add_argument("--source", choices=["codex", "claude"], help="Filter to one source")
|
|
172
233
|
p.add_argument(
|
|
@@ -187,6 +248,25 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
187
248
|
action="store_true",
|
|
188
249
|
help="Include guardian/subagent sessions",
|
|
189
250
|
)
|
|
251
|
+
p.add_argument(
|
|
252
|
+
"--format",
|
|
253
|
+
choices=["text", "json"],
|
|
254
|
+
default="text",
|
|
255
|
+
help="Output format for session reads (default: text)",
|
|
256
|
+
)
|
|
257
|
+
p.add_argument(
|
|
258
|
+
"--show-timestamps",
|
|
259
|
+
action="store_true",
|
|
260
|
+
dest="show_timestamps",
|
|
261
|
+
default=True,
|
|
262
|
+
help="Show message timestamps and elapsed gaps in session reads",
|
|
263
|
+
)
|
|
264
|
+
p.add_argument(
|
|
265
|
+
"--hide-timestamps",
|
|
266
|
+
action="store_false",
|
|
267
|
+
dest="show_timestamps",
|
|
268
|
+
help="Hide message timestamps and elapsed gaps in session reads",
|
|
269
|
+
)
|
|
190
270
|
p.add_argument(
|
|
191
271
|
"--limit",
|
|
192
272
|
type=int,
|
|
@@ -204,7 +284,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
204
284
|
)
|
|
205
285
|
if args.find:
|
|
206
286
|
return cmd_find(
|
|
207
|
-
args.find,
|
|
287
|
+
args.find, # list[str] from nargs="+"
|
|
208
288
|
source_filter=args.source,
|
|
209
289
|
include_subagents=args.include_subagents,
|
|
210
290
|
)
|
|
@@ -214,6 +294,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
214
294
|
verbose=args.verbose,
|
|
215
295
|
include_subagents=args.include_subagents,
|
|
216
296
|
tail=args.tail,
|
|
297
|
+
show_timestamps=args.show_timestamps,
|
|
298
|
+
output_format=args.format,
|
|
217
299
|
)
|
|
218
300
|
|
|
219
301
|
p.print_help()
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import textwrap
|
|
6
|
-
from datetime import datetime
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
7
|
|
|
8
8
|
from agent_chat_reader.models import SessionMeta, Turn
|
|
9
9
|
|
|
@@ -18,11 +18,62 @@ def fmt_ts(ts_str: str) -> str:
|
|
|
18
18
|
"""Format an ISO timestamp to a short local time string."""
|
|
19
19
|
if not ts_str:
|
|
20
20
|
return ""
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
dt = _parse_ts(ts_str)
|
|
22
|
+
if dt is not None:
|
|
23
23
|
return dt.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
24
|
+
return ts_str[:16]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_ts(ts_str: str) -> datetime | None:
|
|
28
|
+
"""Parse an ISO timestamp, returning None for non-ISO placeholders."""
|
|
29
|
+
try:
|
|
30
|
+
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
24
31
|
except ValueError:
|
|
25
|
-
return
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fmt_elapsed(delta: timedelta) -> str:
|
|
36
|
+
"""Format an elapsed duration compactly."""
|
|
37
|
+
seconds = max(0, int(delta.total_seconds()))
|
|
38
|
+
if seconds < 60:
|
|
39
|
+
return f"{seconds}s"
|
|
40
|
+
minutes = seconds // 60
|
|
41
|
+
if minutes < 60:
|
|
42
|
+
return f"{minutes}m"
|
|
43
|
+
hours = minutes // 60
|
|
44
|
+
minutes %= 60
|
|
45
|
+
if hours < 24:
|
|
46
|
+
return f"{hours}h {minutes}m" if minutes else f"{hours}h"
|
|
47
|
+
days = hours // 24
|
|
48
|
+
hours %= 24
|
|
49
|
+
return f"{days}d {hours}h" if hours else f"{days}d"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def elapsed_seconds_between(previous_turn: Turn, turn: Turn) -> int | None:
|
|
53
|
+
"""Return elapsed seconds between two turns, if both timestamps parse."""
|
|
54
|
+
current_dt = _parse_ts(turn.timestamp)
|
|
55
|
+
previous_dt = _parse_ts(previous_turn.timestamp)
|
|
56
|
+
if current_dt is None or previous_dt is None:
|
|
57
|
+
return None
|
|
58
|
+
return max(0, int((current_dt - previous_dt).total_seconds()))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _timestamp_suffix(turn: Turn, previous_turn: Turn | None) -> str:
|
|
62
|
+
"""Return the optional timestamp/gap suffix for a turn header."""
|
|
63
|
+
timestamp = fmt_ts(turn.timestamp)
|
|
64
|
+
if not timestamp:
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
if previous_turn is None:
|
|
68
|
+
return f" {timestamp}"
|
|
69
|
+
|
|
70
|
+
elapsed_seconds = elapsed_seconds_between(previous_turn, turn)
|
|
71
|
+
if elapsed_seconds is None:
|
|
72
|
+
return f" {timestamp}"
|
|
73
|
+
|
|
74
|
+
elapsed = fmt_elapsed(timedelta(seconds=elapsed_seconds))
|
|
75
|
+
role = turn.role.lower()
|
|
76
|
+
return f" {timestamp} ({role} took {elapsed})"
|
|
26
77
|
|
|
27
78
|
|
|
28
79
|
def fmt_mtime(mtime: float) -> str:
|
|
@@ -30,9 +81,14 @@ def fmt_mtime(mtime: float) -> str:
|
|
|
30
81
|
return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M") # noqa: DTZ006
|
|
31
82
|
|
|
32
83
|
|
|
33
|
-
def print_turn(
|
|
84
|
+
def print_turn(
|
|
85
|
+
turn: Turn,
|
|
86
|
+
*,
|
|
87
|
+
show_timestamps: bool = True,
|
|
88
|
+
previous_turn: Turn | None = None,
|
|
89
|
+
) -> None:
|
|
34
90
|
"""Print a single conversation turn with a header rule."""
|
|
35
|
-
ts_str =
|
|
91
|
+
ts_str = _timestamp_suffix(turn, previous_turn) if show_timestamps else ""
|
|
36
92
|
print(f"\n{'─' * 60}")
|
|
37
93
|
print(f"[{turn.role}]{ts_str}")
|
|
38
94
|
print("─" * 60)
|
|
@@ -9,9 +9,10 @@ import pytest
|
|
|
9
9
|
|
|
10
10
|
import agent_chat_reader
|
|
11
11
|
from agent_chat_reader.claude import _extract_user_text
|
|
12
|
-
from agent_chat_reader.cli import main
|
|
12
|
+
from agent_chat_reader.cli import _json_session, main
|
|
13
13
|
from agent_chat_reader.codex import _apply_tail, _session_id_from_path, read_turns
|
|
14
14
|
from agent_chat_reader.models import Turn
|
|
15
|
+
from agent_chat_reader.output import elapsed_seconds_between, fmt_elapsed, print_turn
|
|
15
16
|
|
|
16
17
|
# ── Version ───────────────────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -154,6 +155,92 @@ def test_apply_tail_none_returns_all() -> None:
|
|
|
154
155
|
assert _apply_tail(turns, tail=None) == turns
|
|
155
156
|
|
|
156
157
|
|
|
158
|
+
# ── Output formatting ────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_print_turn_shows_timestamp_by_default(
|
|
162
|
+
capsys: pytest.CaptureFixture[str],
|
|
163
|
+
) -> None:
|
|
164
|
+
"""The default transcript view includes timing context."""
|
|
165
|
+
print_turn(Turn("USER", "hello", "2026-01-01T12:00:00Z"))
|
|
166
|
+
out = capsys.readouterr().out
|
|
167
|
+
assert "[USER]" in out
|
|
168
|
+
assert "2026-01" in out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_print_turn_can_hide_timestamp(
|
|
172
|
+
capsys: pytest.CaptureFixture[str],
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Timestamp display can be disabled for quieter transcript reads."""
|
|
175
|
+
print_turn(
|
|
176
|
+
Turn("USER", "hello", "2026-01-01T00:00:00Z"),
|
|
177
|
+
show_timestamps=False,
|
|
178
|
+
)
|
|
179
|
+
out = capsys.readouterr().out
|
|
180
|
+
assert "[USER]" in out
|
|
181
|
+
assert "2026-01" not in out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_print_turn_can_show_agent_elapsed_time(
|
|
185
|
+
capsys: pytest.CaptureFixture[str],
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Timestamp mode shows elapsed time for the current speaker."""
|
|
188
|
+
previous_turn = Turn("USER", "hello", "2026-01-01T00:00:00Z")
|
|
189
|
+
print_turn(
|
|
190
|
+
Turn("AGENT", "hi", "2026-01-01T00:02:05Z"),
|
|
191
|
+
show_timestamps=True,
|
|
192
|
+
previous_turn=previous_turn,
|
|
193
|
+
)
|
|
194
|
+
out = capsys.readouterr().out
|
|
195
|
+
assert "[AGENT]" in out
|
|
196
|
+
assert "(agent took 2m)" in out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_print_turn_can_show_user_elapsed_time(
|
|
200
|
+
capsys: pytest.CaptureFixture[str],
|
|
201
|
+
) -> None:
|
|
202
|
+
"""User messages show elapsed time without inferring idle state."""
|
|
203
|
+
previous_turn = Turn("AGENT", "question?", "2026-01-01T00:00:00Z")
|
|
204
|
+
print_turn(
|
|
205
|
+
Turn("USER", "answer", "2026-01-01T00:30:00Z"),
|
|
206
|
+
show_timestamps=True,
|
|
207
|
+
previous_turn=previous_turn,
|
|
208
|
+
)
|
|
209
|
+
assert "(user took 30m)" in capsys.readouterr().out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_fmt_elapsed_handles_long_gaps() -> None:
|
|
213
|
+
"""Elapsed durations stay compact for long sessions."""
|
|
214
|
+
from datetime import timedelta
|
|
215
|
+
|
|
216
|
+
assert fmt_elapsed(timedelta(days=1, hours=2, minutes=3)) == "1d 2h"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_elapsed_seconds_between_returns_machine_readable_gap() -> None:
|
|
220
|
+
"""Elapsed gap calculations are available without parsing display text."""
|
|
221
|
+
previous_turn = Turn("USER", "hello", "2026-01-01T00:00:00Z")
|
|
222
|
+
turn = Turn("AGENT", "hi", "2026-01-01T00:02:05Z")
|
|
223
|
+
assert elapsed_seconds_between(previous_turn, turn) == 125
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_json_session_includes_structured_timing_fields(tmp_path: Path) -> None:
|
|
227
|
+
"""JSON output exposes stable fields for agents and scripts."""
|
|
228
|
+
session = tmp_path / _SESSION_FILE
|
|
229
|
+
turns = [
|
|
230
|
+
Turn("USER", "hello", "2026-01-01T00:00:00Z"),
|
|
231
|
+
Turn("AGENT", "hi", "2026-01-01T00:02:05Z"),
|
|
232
|
+
]
|
|
233
|
+
payload = json.loads(
|
|
234
|
+
_json_session(source="codex", path=session, size_kb=12, turns=turns)
|
|
235
|
+
)
|
|
236
|
+
assert payload["source"] == "codex"
|
|
237
|
+
assert payload["size_kb"] == 12
|
|
238
|
+
assert payload["total_turns"] == 2
|
|
239
|
+
assert payload["turns"][0]["elapsed_seconds"] is None
|
|
240
|
+
assert payload["turns"][1]["elapsed_seconds"] == 125
|
|
241
|
+
assert payload["turns"][1]["text"] == "hi"
|
|
242
|
+
|
|
243
|
+
|
|
157
244
|
# ── Claude parsing ────────────────────────────────────────────────────────────
|
|
158
245
|
|
|
159
246
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|