@iseoane/agent-depot 0.2.0 → 0.3.0
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.
- package/CHANGELOG.md +47 -0
- package/README.md +117 -3
- package/dist/src/app-cli.js +172 -0
- package/dist/src/app-flow.js +457 -0
- package/dist/src/app-latest.js +63 -0
- package/dist/src/app-owned-skills.js +42 -0
- package/dist/src/app-recipes.js +116 -0
- package/dist/src/app-schema.js +45 -0
- package/dist/src/app-version.js +46 -0
- package/dist/src/atomic-file.js +26 -0
- package/dist/src/cli.js +1256 -0
- package/dist/src/git-source.js +409 -0
- package/dist/src/path-safety.js +31 -0
- package/dist/src/process-runner.js +41 -0
- package/dist/src/project-installation.js +1411 -0
- package/dist/src/project-manifest.js +511 -0
- package/dist/src/skill-adoption.js +77 -0
- package/dist/src/skill-discovery.js +467 -0
- package/dist/src/skill-hosts.js +103 -0
- package/dist/src/skill-install.js +374 -0
- package/dist/src/skill-removal.js +119 -0
- package/dist/src/skill-update.js +244 -0
- package/dist/src/source-state.js +240 -0
- package/dist/src/sources.js +391 -0
- package/dist/src/tui/adoption-actions.js +144 -0
- package/dist/src/tui/app-actions.js +157 -0
- package/dist/src/tui/app.js +63 -0
- package/dist/src/tui/batch.js +17 -0
- package/dist/src/tui/bulk-actions.js +243 -0
- package/dist/src/tui/catalog-actions.js +9 -0
- package/dist/src/tui/catalog-browse.js +59 -0
- package/dist/src/tui/catalog-filter.js +20 -0
- package/dist/src/tui/catalog-flow.js +107 -0
- package/dist/src/tui/catalog-installs.js +193 -0
- package/dist/src/tui/catalog-panel.js +47 -0
- package/dist/src/tui/catalog-state.js +92 -0
- package/dist/src/tui/catalog-tree.js +35 -0
- package/dist/src/tui/catalog-view.js +89 -0
- package/dist/src/tui/environment.js +1 -0
- package/dist/src/tui/installations-browse.js +136 -0
- package/dist/src/tui/installations-mode.js +1 -0
- package/dist/src/tui/installations-panel.js +27 -0
- package/dist/src/tui/installations-rows.js +11 -0
- package/dist/src/tui/installations-tree.js +80 -0
- package/dist/src/tui/installations-view.js +154 -0
- package/dist/src/tui/installations.js +172 -0
- package/dist/src/tui/keys.js +31 -0
- package/dist/src/tui/manage-actions.js +174 -0
- package/dist/src/tui/manage-panel.js +65 -0
- package/dist/src/tui/mode-keys.js +61 -0
- package/dist/src/tui/panel-parts.js +23 -0
- package/dist/src/tui/render.js +8 -0
- package/dist/src/tui/sources-mode.js +17 -0
- package/dist/src/tui/sources-view.js +276 -0
- package/dist/src/tui/theme.js +29 -0
- package/dist/src/tui/tree-navigation.js +68 -0
- package/dist/src/tui/tree.js +53 -0
- package/dist/src/tui/unmanaged-actions.js +178 -0
- package/dist/src/tui/updates-flow.js +89 -0
- package/dist/src/tui/updates-keys.js +104 -0
- package/dist/src/tui/updates-mode.js +1 -0
- package/dist/src/tui/updates-model.js +48 -0
- package/dist/src/tui/updates-panel.js +44 -0
- package/dist/src/tui/updates-view.js +101 -0
- package/dist/src/tui/updates.js +230 -0
- package/dist/src/tui/view-state.js +42 -0
- package/dist/src/tui/window.js +31 -0
- package/dist/src/unmanaged-removal.js +60 -0
- package/dist/src/update-batch.js +376 -0
- package/dist/src/update-flow.js +135 -0
- package/dist/src/usage-error.js +7 -0
- package/dist/src/user-global-skill-inventory.js +565 -0
- package/dist/src/user-method.js +14 -0
- package/package.json +7 -4
- package/skills/agent-depot-apprecipe/SKILL.md +44 -0
- package/skills/agent-depot-apprecipe/assets/example.md +25 -0
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
- package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
- package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
|
@@ -1,780 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Tests for the session collector (collect_sessions.py)."""
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import tempfile
|
|
7
|
-
import unittest
|
|
8
|
-
from datetime import datetime, timedelta, timezone
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
from collect_sessions import (
|
|
12
|
-
detect_skills_from_entries,
|
|
13
|
-
discover_skills,
|
|
14
|
-
find_claude_session_files,
|
|
15
|
-
find_grok_session_files,
|
|
16
|
-
find_pi_session_files,
|
|
17
|
-
parse_claude_session,
|
|
18
|
-
parse_codex_session,
|
|
19
|
-
parse_grok_session,
|
|
20
|
-
parse_pi_session,
|
|
21
|
-
parse_zcode_session,
|
|
22
|
-
session_matches_repos,
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
# Additional imports for the CLI-level tests below, which drive main() and
|
|
26
|
-
# parse_args() through the module object itself.
|
|
27
|
-
import contextlib
|
|
28
|
-
import io
|
|
29
|
-
import sys
|
|
30
|
-
from unittest import mock
|
|
31
|
-
|
|
32
|
-
import collect_sessions as cs
|
|
33
|
-
|
|
34
|
-
PREVIOUS_FILE_LIMIT = 8 * 1024 * 1024
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def write_jsonl(path, records):
|
|
38
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
-
path.write_text("\n".join(json.dumps(record) for record in records) + "\n")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def write_jsonl_past_previous_limit(path, first_record, last_records):
|
|
43
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
-
filler = json.dumps({"type": "ignored", "padding": "x" * 1024}) + "\n"
|
|
45
|
-
with path.open("w", encoding="utf-8") as stream:
|
|
46
|
-
stream.write(json.dumps(first_record) + "\n")
|
|
47
|
-
while stream.tell() <= PREVIOUS_FILE_LIMIT:
|
|
48
|
-
stream.write(filler)
|
|
49
|
-
for record in last_records:
|
|
50
|
-
stream.write(json.dumps(record) + "\n")
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class ClaudeSessionTests(unittest.TestCase):
|
|
54
|
-
def test_discovers_skills_and_matches_sessions_across_projects(self):
|
|
55
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
56
|
-
root = Path(tmp)
|
|
57
|
-
first = root / "first"
|
|
58
|
-
second = root / "second"
|
|
59
|
-
first_skill = first / ".agents" / "skills" / "alpha" / "SKILL.md"
|
|
60
|
-
second_skill = second / ".claude" / "skills" / "beta" / "SKILL.md"
|
|
61
|
-
first_skill.parent.mkdir(parents=True)
|
|
62
|
-
second_skill.parent.mkdir(parents=True)
|
|
63
|
-
first_skill.write_text("---\ndescription: Alpha\n---\n")
|
|
64
|
-
second_skill.write_text("---\ndescription: Beta\n---\n")
|
|
65
|
-
|
|
66
|
-
skills = discover_skills(
|
|
67
|
-
[first, second],
|
|
68
|
-
root / "codex-home",
|
|
69
|
-
[],
|
|
70
|
-
False,
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
self.assertEqual(set(skills), {"alpha", "beta"})
|
|
74
|
-
self.assertTrue(
|
|
75
|
-
session_matches_repos(second / "src", [first, second])
|
|
76
|
-
)
|
|
77
|
-
self.assertFalse(
|
|
78
|
-
session_matches_repos(root / "elsewhere", [first, second])
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
def test_discovers_global_skills_without_repositories(self):
|
|
82
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
83
|
-
root = Path(tmp)
|
|
84
|
-
homes = {
|
|
85
|
-
name: root / f"{name}-home"
|
|
86
|
-
for name in ("codex", "pi", "grok", "zcode")
|
|
87
|
-
}
|
|
88
|
-
for name, home in homes.items():
|
|
89
|
-
skill = home / "skills" / f"{name}-skill" / "SKILL.md"
|
|
90
|
-
skill.parent.mkdir(parents=True)
|
|
91
|
-
skill.write_text(f"---\ndescription: {name.title()} skill\n---\n")
|
|
92
|
-
|
|
93
|
-
skills = discover_skills(
|
|
94
|
-
[],
|
|
95
|
-
homes["codex"],
|
|
96
|
-
[],
|
|
97
|
-
True,
|
|
98
|
-
pi_home=homes["pi"],
|
|
99
|
-
grok_home=homes["grok"],
|
|
100
|
-
zcode_home=homes["zcode"],
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
self.assertTrue(
|
|
104
|
-
{
|
|
105
|
-
"codex-skill",
|
|
106
|
-
"pi-skill",
|
|
107
|
-
"grok-skill",
|
|
108
|
-
"zcode-skill",
|
|
109
|
-
}.issubset(skills)
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
def test_detects_skills_from_deferred_tool_entries(self):
|
|
113
|
-
entries = [
|
|
114
|
-
("tool:Skill", '{"skill": "alpha"}'),
|
|
115
|
-
("tool:read", '{"path": "/repo/.agents/skills/beta/SKILL.md"}'),
|
|
116
|
-
("assistant", "Mentioning gamma here does not count."),
|
|
117
|
-
]
|
|
118
|
-
|
|
119
|
-
self.assertEqual(
|
|
120
|
-
detect_skills_from_entries(entries, {"alpha", "beta", "gamma"}),
|
|
121
|
-
{"alpha", "beta"},
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
def test_discovers_parent_sessions_and_optional_subagents(self):
|
|
125
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
126
|
-
claude_home = Path(tmp)
|
|
127
|
-
parent = claude_home / "projects" / "-repo" / "parent.jsonl"
|
|
128
|
-
subagent = (
|
|
129
|
-
claude_home
|
|
130
|
-
/ "projects"
|
|
131
|
-
/ "-repo"
|
|
132
|
-
/ "parent"
|
|
133
|
-
/ "subagents"
|
|
134
|
-
/ "agent-child.jsonl"
|
|
135
|
-
)
|
|
136
|
-
old = claude_home / "projects" / "-repo" / "old.jsonl"
|
|
137
|
-
for path in (parent, subagent, old):
|
|
138
|
-
write_jsonl(path, [{"type": "user"}])
|
|
139
|
-
old_time = (datetime.now(timezone.utc) - timedelta(days=10)).timestamp()
|
|
140
|
-
os.utime(old, (old_time, old_time))
|
|
141
|
-
cutoff = datetime.now(timezone.utc) - timedelta(days=1)
|
|
142
|
-
|
|
143
|
-
parents = find_claude_session_files(claude_home, cutoff, False)
|
|
144
|
-
with_subagents = find_claude_session_files(claude_home, cutoff, True)
|
|
145
|
-
|
|
146
|
-
self.assertEqual([path for _, path in parents], [parent])
|
|
147
|
-
self.assertEqual(
|
|
148
|
-
{path for _, path in with_subagents},
|
|
149
|
-
{parent, subagent},
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
def test_parses_messages_tools_skills_and_stats(self):
|
|
153
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
154
|
-
path = Path(tmp) / "session.jsonl"
|
|
155
|
-
common = {
|
|
156
|
-
"sessionId": "session-1",
|
|
157
|
-
"cwd": "/tmp/repo",
|
|
158
|
-
"timestamp": "2026-08-20T10:00:00Z",
|
|
159
|
-
"version": "1.0.0",
|
|
160
|
-
}
|
|
161
|
-
write_jsonl(path, [
|
|
162
|
-
{
|
|
163
|
-
**common,
|
|
164
|
-
"type": "user",
|
|
165
|
-
"uuid": "user-1",
|
|
166
|
-
"message": {"role": "user", "content": "Improve my skill"},
|
|
167
|
-
},
|
|
168
|
-
{
|
|
169
|
-
**common,
|
|
170
|
-
"type": "assistant",
|
|
171
|
-
"uuid": "assistant-1",
|
|
172
|
-
"message": {
|
|
173
|
-
"id": "message-1",
|
|
174
|
-
"role": "assistant",
|
|
175
|
-
"content": [
|
|
176
|
-
{"type": "text", "text": "I will inspect it."},
|
|
177
|
-
{
|
|
178
|
-
"type": "tool_use",
|
|
179
|
-
"name": "Skill",
|
|
180
|
-
"input": {"skill": "update-skill"},
|
|
181
|
-
},
|
|
182
|
-
],
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
{
|
|
186
|
-
**common,
|
|
187
|
-
"type": "assistant",
|
|
188
|
-
"uuid": "assistant-2",
|
|
189
|
-
"message": {
|
|
190
|
-
"id": "message-1",
|
|
191
|
-
"role": "assistant",
|
|
192
|
-
"content": [
|
|
193
|
-
{
|
|
194
|
-
"type": "tool_use",
|
|
195
|
-
"name": "Edit",
|
|
196
|
-
"input": {"file_path": "/tmp/repo/SKILL.md"},
|
|
197
|
-
}
|
|
198
|
-
],
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
**common,
|
|
203
|
-
"type": "user",
|
|
204
|
-
"uuid": "result-1",
|
|
205
|
-
"message": {
|
|
206
|
-
"role": "user",
|
|
207
|
-
"content": [
|
|
208
|
-
{
|
|
209
|
-
"type": "tool_result",
|
|
210
|
-
"is_error": True,
|
|
211
|
-
"content": "permission denied",
|
|
212
|
-
}
|
|
213
|
-
],
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
|
-
])
|
|
217
|
-
|
|
218
|
-
meta, stats, entries, skills = parse_claude_session(
|
|
219
|
-
path,
|
|
220
|
-
{"update-skill"},
|
|
221
|
-
False,
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
self.assertEqual(meta["id"], "session-1")
|
|
225
|
-
self.assertEqual(meta["cwd"], "/tmp/repo")
|
|
226
|
-
self.assertEqual(stats["user_turns"], 1)
|
|
227
|
-
self.assertEqual(stats["assistant_turns"], 1)
|
|
228
|
-
self.assertEqual(stats["tool_calls"], 2)
|
|
229
|
-
self.assertEqual(stats["error_outputs"], 1)
|
|
230
|
-
self.assertTrue(stats["has_code_edits"])
|
|
231
|
-
self.assertEqual(skills, ["update-skill"])
|
|
232
|
-
self.assertIn(("user", "Improve my skill"), entries)
|
|
233
|
-
self.assertIn(("assistant", "I will inspect it."), entries)
|
|
234
|
-
|
|
235
|
-
def test_excludes_sidechains_by_default(self):
|
|
236
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
237
|
-
path = Path(tmp) / "agent-child.jsonl"
|
|
238
|
-
write_jsonl(path, [{
|
|
239
|
-
"type": "user",
|
|
240
|
-
"sessionId": "session-1",
|
|
241
|
-
"agentId": "child-1",
|
|
242
|
-
"isSidechain": True,
|
|
243
|
-
"cwd": "/tmp/repo",
|
|
244
|
-
"timestamp": "2026-08-20T10:00:00Z",
|
|
245
|
-
"message": {"role": "user", "content": "Investigate"},
|
|
246
|
-
}])
|
|
247
|
-
|
|
248
|
-
self.assertIsNone(parse_claude_session(path, set(), False))
|
|
249
|
-
parsed = parse_claude_session(path, set(), True)
|
|
250
|
-
self.assertEqual(parsed[0]["id"], "session-1-child-1")
|
|
251
|
-
self.assertEqual(parsed[0]["thread_source"], "subagent")
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
class PiSessionTests(unittest.TestCase):
|
|
255
|
-
def test_parses_messages_tools_skills_and_stats(self):
|
|
256
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
257
|
-
path = Path(tmp) / "2026-08-20T10-00-00-000Z_session-1.jsonl"
|
|
258
|
-
write_jsonl(path, [
|
|
259
|
-
{
|
|
260
|
-
"type": "session",
|
|
261
|
-
"id": "session-1",
|
|
262
|
-
"cwd": "/tmp/repo",
|
|
263
|
-
"timestamp": "2026-08-20T10:00:00.000Z",
|
|
264
|
-
},
|
|
265
|
-
{"type": "model_change", "provider": "zai", "modelId": "glm"},
|
|
266
|
-
{
|
|
267
|
-
"type": "message",
|
|
268
|
-
"timestamp": "2026-08-20T10:00:01.000Z",
|
|
269
|
-
"message": {
|
|
270
|
-
"role": "user",
|
|
271
|
-
"content": [{"type": "text", "text": "Improve my skill"}],
|
|
272
|
-
},
|
|
273
|
-
},
|
|
274
|
-
{
|
|
275
|
-
"type": "message",
|
|
276
|
-
"timestamp": "2026-08-20T10:00:02.000Z",
|
|
277
|
-
"message": {
|
|
278
|
-
"role": "assistant",
|
|
279
|
-
"content": [
|
|
280
|
-
{"type": "thinking", "thinking": "plan the edit"},
|
|
281
|
-
{"type": "text", "text": "I will inspect it."},
|
|
282
|
-
{
|
|
283
|
-
"type": "toolCall",
|
|
284
|
-
"name": "read",
|
|
285
|
-
"arguments": {
|
|
286
|
-
"path": "/repo/.agents/skills/update-skill/SKILL.md"
|
|
287
|
-
},
|
|
288
|
-
},
|
|
289
|
-
],
|
|
290
|
-
},
|
|
291
|
-
},
|
|
292
|
-
{
|
|
293
|
-
"type": "message",
|
|
294
|
-
"timestamp": "2026-08-20T10:00:03.000Z",
|
|
295
|
-
"message": {
|
|
296
|
-
"role": "toolResult",
|
|
297
|
-
"toolName": "read",
|
|
298
|
-
"content": [{"type": "text", "text": "--- description: alpha"}],
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
{
|
|
302
|
-
"type": "message",
|
|
303
|
-
"timestamp": "2026-08-20T10:00:04.000Z",
|
|
304
|
-
"message": {
|
|
305
|
-
"role": "assistant",
|
|
306
|
-
"content": [
|
|
307
|
-
{
|
|
308
|
-
"type": "toolCall",
|
|
309
|
-
"name": "edit",
|
|
310
|
-
"arguments": {
|
|
311
|
-
"path": "/tmp/repo/SKILL.md",
|
|
312
|
-
"oldText": "a",
|
|
313
|
-
"newText": "b",
|
|
314
|
-
},
|
|
315
|
-
},
|
|
316
|
-
],
|
|
317
|
-
},
|
|
318
|
-
},
|
|
319
|
-
{
|
|
320
|
-
"type": "message",
|
|
321
|
-
"timestamp": "2026-08-20T10:00:05.000Z",
|
|
322
|
-
"message": {
|
|
323
|
-
"role": "toolResult",
|
|
324
|
-
"toolName": "edit",
|
|
325
|
-
"content": [
|
|
326
|
-
{"type": "text", "text": "oldText not found", "isError": True}
|
|
327
|
-
],
|
|
328
|
-
},
|
|
329
|
-
},
|
|
330
|
-
])
|
|
331
|
-
|
|
332
|
-
meta, stats, entries, skills = parse_pi_session(path, {"update-skill"}, False)
|
|
333
|
-
|
|
334
|
-
self.assertEqual(meta["id"], "session-1")
|
|
335
|
-
self.assertEqual(meta["cwd"], "/tmp/repo")
|
|
336
|
-
self.assertEqual(meta["originator"], "pi")
|
|
337
|
-
self.assertEqual(stats["user_turns"], 1)
|
|
338
|
-
self.assertEqual(stats["assistant_turns"], 2)
|
|
339
|
-
self.assertEqual(stats["tool_calls"], 2)
|
|
340
|
-
self.assertEqual(stats["repeated_tool_calls"], 0)
|
|
341
|
-
self.assertEqual(stats["error_outputs"], 1)
|
|
342
|
-
self.assertTrue(stats["has_code_edits"])
|
|
343
|
-
self.assertEqual(stats["first_ts"], "2026-08-20T10:00:00.000Z")
|
|
344
|
-
self.assertEqual(stats["last_ts"], "2026-08-20T10:00:05.000Z")
|
|
345
|
-
self.assertEqual(skills, ["update-skill"])
|
|
346
|
-
self.assertIn(("user", "Improve my skill"), entries)
|
|
347
|
-
self.assertIn(("assistant", "I will inspect it."), entries)
|
|
348
|
-
self.assertIn(("output", "--- description: alpha"), entries)
|
|
349
|
-
self.assertFalse(any(role == "thinking" for role, _ in entries))
|
|
350
|
-
|
|
351
|
-
def test_accepts_include_subagents_without_changing_result(self):
|
|
352
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
353
|
-
path = Path(tmp) / "session-2.jsonl"
|
|
354
|
-
write_jsonl(path, [
|
|
355
|
-
{"type": "session", "id": "session-2", "cwd": "/tmp/repo",
|
|
356
|
-
"timestamp": "2026-08-20T10:00:00.000Z"},
|
|
357
|
-
{"type": "message", "timestamp": "2026-08-20T10:00:01.000Z",
|
|
358
|
-
"message": {"role": "user", "content": "Hello there"}},
|
|
359
|
-
])
|
|
360
|
-
|
|
361
|
-
without_flag = parse_pi_session(path, set(), False)
|
|
362
|
-
with_flag = parse_pi_session(path, set(), True)
|
|
363
|
-
|
|
364
|
-
self.assertEqual(without_flag, with_flag)
|
|
365
|
-
self.assertEqual(with_flag[0]["originator"], "pi")
|
|
366
|
-
|
|
367
|
-
def test_finds_recent_session_files_only(self):
|
|
368
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
369
|
-
pi_home = Path(tmp)
|
|
370
|
-
recent = pi_home / "sessions" / "--tmp-repo" / "recent.jsonl"
|
|
371
|
-
old = pi_home / "sessions" / "--tmp-repo" / "old.jsonl"
|
|
372
|
-
for path in (recent, old):
|
|
373
|
-
write_jsonl(path, [{"type": "session", "id": "x"}])
|
|
374
|
-
old_time = (datetime.now(timezone.utc) - timedelta(days=10)).timestamp()
|
|
375
|
-
os.utime(old, (old_time, old_time))
|
|
376
|
-
cutoff = datetime.now(timezone.utc) - timedelta(days=1)
|
|
377
|
-
|
|
378
|
-
files = find_pi_session_files(pi_home, cutoff)
|
|
379
|
-
|
|
380
|
-
self.assertEqual([path for _, path in files], [recent])
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
class GrokSessionTests(unittest.TestCase):
|
|
384
|
-
def test_parses_tool_calls_and_skips_synthetic_messages(self):
|
|
385
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
386
|
-
grok_home = Path(tmp)
|
|
387
|
-
session_dir = grok_home / "sessions" / "%2Ftmp%2Frepo" / "abc-123"
|
|
388
|
-
path = session_dir / "chat_history.jsonl"
|
|
389
|
-
write_jsonl(path, [
|
|
390
|
-
{"type": "system", "content": "You are Grok."},
|
|
391
|
-
{
|
|
392
|
-
"type": "user",
|
|
393
|
-
"synthetic_reason": "system_reminder",
|
|
394
|
-
"content": [{"type": "text", "text": "<system-reminder>skills</system-reminder>"}],
|
|
395
|
-
},
|
|
396
|
-
{"type": "user", "content": "Fix the collector"},
|
|
397
|
-
{
|
|
398
|
-
"type": "reasoning",
|
|
399
|
-
"id": "rs-1",
|
|
400
|
-
"summary": "[]",
|
|
401
|
-
"encrypted_content": "xx",
|
|
402
|
-
"status": "completed",
|
|
403
|
-
},
|
|
404
|
-
{
|
|
405
|
-
"type": "assistant",
|
|
406
|
-
"content": "I will read the skill.",
|
|
407
|
-
"tool_calls": [{
|
|
408
|
-
"id": "call-1",
|
|
409
|
-
"name": "read_file",
|
|
410
|
-
"arguments": "{\"path\": \"/repo/.grok/skills/update-skill/SKILL.md\"}",
|
|
411
|
-
}],
|
|
412
|
-
},
|
|
413
|
-
{"type": "tool_result", "tool_call_id": "call-1", "content": "file body"},
|
|
414
|
-
{
|
|
415
|
-
"type": "assistant",
|
|
416
|
-
"content": "Now writing the file.",
|
|
417
|
-
"tool_calls": [{
|
|
418
|
-
"id": "call-2",
|
|
419
|
-
"name": "write",
|
|
420
|
-
"arguments": "{\"path\": \"/tmp/repo/out.txt\", \"content\": \"hi\"}",
|
|
421
|
-
}],
|
|
422
|
-
},
|
|
423
|
-
])
|
|
424
|
-
cutoff = datetime.now(timezone.utc) - timedelta(days=1)
|
|
425
|
-
|
|
426
|
-
files = find_grok_session_files(grok_home, cutoff)
|
|
427
|
-
self.assertEqual([found for _, found in files], [path])
|
|
428
|
-
|
|
429
|
-
meta, stats, entries, skills = parse_grok_session(path, {"update-skill"}, False)
|
|
430
|
-
|
|
431
|
-
self.assertEqual(meta["id"], "abc-123")
|
|
432
|
-
self.assertEqual(meta["cwd"], "/tmp/repo")
|
|
433
|
-
self.assertEqual(meta["originator"], "grok")
|
|
434
|
-
self.assertEqual(stats["user_turns"], 1)
|
|
435
|
-
self.assertEqual(stats["assistant_turns"], 2)
|
|
436
|
-
self.assertEqual(stats["tool_calls"], 2)
|
|
437
|
-
self.assertEqual(stats["error_outputs"], 0)
|
|
438
|
-
self.assertTrue(stats["has_code_edits"])
|
|
439
|
-
self.assertEqual(skills, ["update-skill"])
|
|
440
|
-
self.assertEqual(entries[0], ("user", "Fix the collector"))
|
|
441
|
-
self.assertNotIn("system-reminder", json.dumps(entries))
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
class ZcodeSessionTests(unittest.TestCase):
|
|
445
|
-
def test_parses_last_request_messages_and_tool_calls(self):
|
|
446
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
447
|
-
path = Path(tmp) / "model-io-sess_1.jsonl"
|
|
448
|
-
empty_request = {"request": {"body": {"messages": []}}}
|
|
449
|
-
full_request = {
|
|
450
|
-
"request": {
|
|
451
|
-
"body": {
|
|
452
|
-
"messages": [
|
|
453
|
-
{"role": "system", "content": "You are ZCode."},
|
|
454
|
-
{"role": "user", "content": "Update the skill"},
|
|
455
|
-
{
|
|
456
|
-
"role": "assistant",
|
|
457
|
-
"content": "Checking.",
|
|
458
|
-
"tool_calls": [{
|
|
459
|
-
"id": "c1",
|
|
460
|
-
"function": {
|
|
461
|
-
"name": "read_file",
|
|
462
|
-
"arguments": "{\"path\": \"/r/.agents/skills/update-skill/SKILL.md\"}",
|
|
463
|
-
},
|
|
464
|
-
}],
|
|
465
|
-
},
|
|
466
|
-
{"role": "tool", "content": "skill body"},
|
|
467
|
-
]
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
path.write_text(
|
|
472
|
-
json.dumps(empty_request) + "\n" + json.dumps(full_request) + "\n"
|
|
473
|
-
)
|
|
474
|
-
|
|
475
|
-
meta, stats, entries, skills = parse_zcode_session(path, {"update-skill"}, False)
|
|
476
|
-
|
|
477
|
-
self.assertEqual(meta["id"], "sess_1")
|
|
478
|
-
self.assertEqual(meta["originator"], "zcode")
|
|
479
|
-
self.assertEqual(stats["user_turns"], 1)
|
|
480
|
-
self.assertEqual(stats["assistant_turns"], 1)
|
|
481
|
-
self.assertEqual(stats["tool_calls"], 1)
|
|
482
|
-
self.assertEqual(skills, ["update-skill"])
|
|
483
|
-
self.assertEqual(entries[0], ("user", "Update the skill"))
|
|
484
|
-
|
|
485
|
-
def test_returns_none_without_request_records(self):
|
|
486
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
487
|
-
path = Path(tmp) / "model-io-sess_2.jsonl"
|
|
488
|
-
path.write_text("{\"unrelated\": true}\n")
|
|
489
|
-
|
|
490
|
-
self.assertIsNone(parse_zcode_session(path, set(), False))
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
class StreamingSessionReadTests(unittest.TestCase):
|
|
494
|
-
def test_claude_and_codex_parse_records_after_previous_file_limit(self):
|
|
495
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
496
|
-
root = Path(tmp)
|
|
497
|
-
claude_path = root / "claude.jsonl"
|
|
498
|
-
codex_path = root / "codex.jsonl"
|
|
499
|
-
write_jsonl_past_previous_limit(
|
|
500
|
-
claude_path,
|
|
501
|
-
{
|
|
502
|
-
"type": "user",
|
|
503
|
-
"sessionId": "claude-session",
|
|
504
|
-
"cwd": "/tmp/repo",
|
|
505
|
-
"timestamp": "2026-08-30T10:00:00Z",
|
|
506
|
-
"message": {"role": "user", "content": "hello"},
|
|
507
|
-
},
|
|
508
|
-
[{
|
|
509
|
-
"type": "assistant",
|
|
510
|
-
"sessionId": "claude-session",
|
|
511
|
-
"cwd": "/tmp/repo",
|
|
512
|
-
"timestamp": "2026-08-30T10:01:00Z",
|
|
513
|
-
"message": {
|
|
514
|
-
"id": "late-message",
|
|
515
|
-
"role": "assistant",
|
|
516
|
-
"content": [
|
|
517
|
-
{"type": "text", "text": "late claude message"},
|
|
518
|
-
{
|
|
519
|
-
"type": "tool_use",
|
|
520
|
-
"name": "Read",
|
|
521
|
-
"input": {
|
|
522
|
-
"file_path": "/tmp/.agents/skills/late-skill/SKILL.md"
|
|
523
|
-
},
|
|
524
|
-
},
|
|
525
|
-
],
|
|
526
|
-
},
|
|
527
|
-
}],
|
|
528
|
-
)
|
|
529
|
-
write_jsonl_past_previous_limit(
|
|
530
|
-
codex_path,
|
|
531
|
-
{
|
|
532
|
-
"type": "session_meta",
|
|
533
|
-
"payload": {"id": "codex-session", "cwd": "/tmp/repo"},
|
|
534
|
-
},
|
|
535
|
-
[{
|
|
536
|
-
"type": "response_item",
|
|
537
|
-
"timestamp": "2026-08-30T10:00:30Z",
|
|
538
|
-
"payload": {
|
|
539
|
-
"type": "function_call",
|
|
540
|
-
"name": "read_file",
|
|
541
|
-
"arguments": {
|
|
542
|
-
"path": "/tmp/.codex/skills/late-skill/SKILL.md",
|
|
543
|
-
"operation": "apply_patch",
|
|
544
|
-
},
|
|
545
|
-
},
|
|
546
|
-
}, {
|
|
547
|
-
"type": "response_item",
|
|
548
|
-
"timestamp": "2026-08-30T10:01:00Z",
|
|
549
|
-
"payload": {
|
|
550
|
-
"type": "message",
|
|
551
|
-
"role": "assistant",
|
|
552
|
-
"content": [{"type": "text", "text": "late codex message"}],
|
|
553
|
-
},
|
|
554
|
-
}],
|
|
555
|
-
)
|
|
556
|
-
|
|
557
|
-
parsed_claude = parse_claude_session(claude_path, {"late-skill"}, False)
|
|
558
|
-
parsed_codex = parse_codex_session(codex_path, {"late-skill"}, False)
|
|
559
|
-
|
|
560
|
-
self.assertEqual(parsed_claude[1]["assistant_turns"], 1)
|
|
561
|
-
self.assertEqual(parsed_claude[1]["tool_calls"], 1)
|
|
562
|
-
self.assertEqual(parsed_claude[3], ["late-skill"])
|
|
563
|
-
self.assertIn(("assistant", "late claude message"), parsed_claude[2])
|
|
564
|
-
self.assertEqual(parsed_codex[1]["tool_calls"], 1)
|
|
565
|
-
self.assertTrue(parsed_codex[1]["has_code_edits"])
|
|
566
|
-
self.assertEqual(parsed_codex[3], ["late-skill"])
|
|
567
|
-
self.assertIn(("assistant", "late codex message"), parsed_codex[2])
|
|
568
|
-
|
|
569
|
-
def test_codex_keeps_only_transcript_head_and_tail_while_parsing(self):
|
|
570
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
571
|
-
path = Path(tmp) / "codex.jsonl"
|
|
572
|
-
records = [{
|
|
573
|
-
"type": "session_meta",
|
|
574
|
-
"payload": {"id": "codex-session", "cwd": "/tmp/repo"},
|
|
575
|
-
}]
|
|
576
|
-
for index in range(201):
|
|
577
|
-
records.extend(({
|
|
578
|
-
"type": "event_msg",
|
|
579
|
-
"payload": {"type": "agent_message"},
|
|
580
|
-
}, {
|
|
581
|
-
"type": "response_item",
|
|
582
|
-
"payload": {
|
|
583
|
-
"type": "message",
|
|
584
|
-
"role": "assistant",
|
|
585
|
-
"content": [{"type": "text", "text": f"message-{index}"}],
|
|
586
|
-
},
|
|
587
|
-
}))
|
|
588
|
-
write_jsonl(path, records)
|
|
589
|
-
|
|
590
|
-
_, stats, entries, _ = parse_codex_session(path, set(), False)
|
|
591
|
-
|
|
592
|
-
self.assertEqual(stats["assistant_turns"], 201)
|
|
593
|
-
self.assertEqual(len(entries), 141)
|
|
594
|
-
self.assertEqual(entries[0], ("assistant", "message-0"))
|
|
595
|
-
self.assertEqual(entries[99], ("assistant", "message-99"))
|
|
596
|
-
self.assertEqual(entries[100], ("note", "[... 61 entries omitted ...]"))
|
|
597
|
-
self.assertEqual(entries[101], ("assistant", "message-161"))
|
|
598
|
-
self.assertEqual(entries[-1], ("assistant", "message-200"))
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
# ---------------------------------------------------------------------------
|
|
602
|
-
# CLI-level tests: --sample modes and --harness validation.
|
|
603
|
-
# ---------------------------------------------------------------------------
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
def write_claude_session_file(path, session_id, cwd="/nonexistent/repo", skill=None,
|
|
607
|
-
timestamp="2026-08-20T10:00:00Z"):
|
|
608
|
-
"""A minimal Claude Code session with exactly one user turn and one
|
|
609
|
-
assistant turn carrying a tool_use call -- main() drops any session with
|
|
610
|
-
zero assistant_turns or zero tool_calls before sampling even sees it, so
|
|
611
|
-
every sampling fixture needs at least this much to be considered at all.
|
|
612
|
-
`cwd` deliberately does not exist on disk: --all-conversations re-infers
|
|
613
|
-
repos via `git -C <cwd> rev-parse ...`, and a non-existent cwd short-
|
|
614
|
-
circuits that (Path.is_dir() is False) before any subprocess runs.
|
|
615
|
-
"""
|
|
616
|
-
common = {"sessionId": session_id, "cwd": cwd, "timestamp": timestamp}
|
|
617
|
-
if skill:
|
|
618
|
-
tool_block = {"type": "tool_use", "name": "Skill", "input": {"skill": skill}}
|
|
619
|
-
else:
|
|
620
|
-
tool_block = {"type": "tool_use", "name": "Edit", "input": {"file_path": f"{cwd}/file.py"}}
|
|
621
|
-
write_jsonl(path, [
|
|
622
|
-
{**common, "type": "user", "uuid": f"{session_id}-user-1",
|
|
623
|
-
"message": {"role": "user", "content": "hello"}},
|
|
624
|
-
{**common, "type": "assistant", "uuid": f"{session_id}-assistant-1",
|
|
625
|
-
"message": {"id": f"{session_id}-m1", "role": "assistant",
|
|
626
|
-
"content": [{"type": "text", "text": "working on it"}, tool_block]}},
|
|
627
|
-
])
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
def set_mtime(path, seconds_ago):
|
|
631
|
-
"""Give a fixture file a precise, distinct mtime so sampling order
|
|
632
|
-
(which sorts by file mtime, newest first) is deterministic in a test."""
|
|
633
|
-
t = (datetime.now(timezone.utc) - timedelta(seconds=seconds_ago)).timestamp()
|
|
634
|
-
os.utime(path, (t, t))
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
def write_skill_fixture(skills_dir, name):
|
|
638
|
-
skill_md = skills_dir / name / "SKILL.md"
|
|
639
|
-
skill_md.parent.mkdir(parents=True, exist_ok=True)
|
|
640
|
-
skill_md.write_text(f"---\ndescription: {name.title()}\n---\n")
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
def run_collect(claude_home, out_dir, extra_args=None, **kwargs):
|
|
644
|
-
"""Drive collect_sessions.main() through its real CLI. main() has no
|
|
645
|
-
argv parameter of its own (unlike this skill's other scripts) -- it
|
|
646
|
-
always reads sys.argv, so the only faithful way to exercise the actual
|
|
647
|
-
argument-parsing and sampling pipeline is to patch sys.argv, exactly as
|
|
648
|
-
a real invocation would populate it, and let main() run for real."""
|
|
649
|
-
argv = [
|
|
650
|
-
"collect_sessions.py", "--out", str(out_dir), "--claude-home", str(claude_home),
|
|
651
|
-
"--all-conversations", "--harness", "claude", "--days", "3650",
|
|
652
|
-
]
|
|
653
|
-
argv.extend(extra_args or [])
|
|
654
|
-
for key, value in kwargs.items():
|
|
655
|
-
flag = "--" + key.replace("_", "-")
|
|
656
|
-
argv.extend([flag, str(value)])
|
|
657
|
-
with mock.patch.object(sys, "argv", argv):
|
|
658
|
-
cs.main()
|
|
659
|
-
return json.loads((Path(out_dir) / "inventory.json").read_text())
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
class SamplingTests(unittest.TestCase):
|
|
663
|
-
"""--sample {newest,skill-stratified} selects how sessions are sampled."""
|
|
664
|
-
|
|
665
|
-
def test_newest_sample_selects_n_distinct_sessions_despite_resumed_duplicate(self):
|
|
666
|
-
# The trap the newest-sample comment in collect_sessions.py warns
|
|
667
|
-
# about: the same session id can appear via more than one file (a
|
|
668
|
-
# resumed session). The sampled set is keyed by `_key`
|
|
669
|
-
# (harness:id), so distinctness must be measured by that key set's
|
|
670
|
-
# size, not by counting list entries or slicing the first N -- a
|
|
671
|
-
# naive `sessions[:max_sessions]` here would count the resumed
|
|
672
|
-
# session's two file-entries as two sessions and under-sample by one
|
|
673
|
-
# distinct session.
|
|
674
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
675
|
-
claude_home = Path(tmp) / "claude"
|
|
676
|
-
proj = claude_home / "projects" / "p1"
|
|
677
|
-
resumed_a = proj / "resumed-a.jsonl"
|
|
678
|
-
resumed_b = proj / "resumed-b.jsonl"
|
|
679
|
-
session_b = proj / "session-b.jsonl"
|
|
680
|
-
session_c = proj / "session-c.jsonl"
|
|
681
|
-
write_claude_session_file(resumed_a, "resumed-session")
|
|
682
|
-
write_claude_session_file(resumed_b, "resumed-session") # same id, second file
|
|
683
|
-
write_claude_session_file(session_b, "session-b")
|
|
684
|
-
write_claude_session_file(session_c, "session-c")
|
|
685
|
-
set_mtime(resumed_a, 10) # newest
|
|
686
|
-
set_mtime(resumed_b, 20) # 2nd newest, SAME session id as resumed_a
|
|
687
|
-
set_mtime(session_b, 30) # 3rd newest, distinct
|
|
688
|
-
set_mtime(session_c, 40) # oldest, distinct -- must be excluded at max_sessions=2
|
|
689
|
-
|
|
690
|
-
inventory = run_collect(claude_home, Path(tmp) / "out",
|
|
691
|
-
sample="newest", max_sessions=2)
|
|
692
|
-
|
|
693
|
-
self.assertEqual(inventory["stats"]["sessions_sampled"], 2)
|
|
694
|
-
sampled_ids = {s["meta"]["id"] for s in inventory["sessions"] if s["sampled"]}
|
|
695
|
-
self.assertEqual(len(sampled_ids), 2)
|
|
696
|
-
self.assertEqual(sampled_ids, {"resumed-session", "session-b"})
|
|
697
|
-
self.assertNotIn("session-c", sampled_ids)
|
|
698
|
-
|
|
699
|
-
def test_newest_sample_ignores_skill_usage(self):
|
|
700
|
-
# The skill-stratified mode draws skill-using sessions first,
|
|
701
|
-
# regardless of recency. --sample newest must NOT do that -- the
|
|
702
|
-
# newest sessions win even when an older session is the only one
|
|
703
|
-
# that used the sole installed skill.
|
|
704
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
705
|
-
claude_home = Path(tmp) / "claude"
|
|
706
|
-
proj = claude_home / "projects" / "p1"
|
|
707
|
-
skills_dir = Path(tmp) / "skills-fixture"
|
|
708
|
-
write_skill_fixture(skills_dir, "alpha")
|
|
709
|
-
|
|
710
|
-
newest1 = proj / "newest1.jsonl"
|
|
711
|
-
newest2 = proj / "newest2.jsonl"
|
|
712
|
-
older_with_skill = proj / "older-skill.jsonl"
|
|
713
|
-
oldest = proj / "oldest.jsonl"
|
|
714
|
-
write_claude_session_file(newest1, "newest-1")
|
|
715
|
-
write_claude_session_file(newest2, "newest-2")
|
|
716
|
-
write_claude_session_file(older_with_skill, "older-skill-session", skill="alpha")
|
|
717
|
-
write_claude_session_file(oldest, "oldest-session")
|
|
718
|
-
set_mtime(newest1, 10)
|
|
719
|
-
set_mtime(newest2, 20)
|
|
720
|
-
set_mtime(older_with_skill, 30)
|
|
721
|
-
set_mtime(oldest, 40)
|
|
722
|
-
|
|
723
|
-
inventory = run_collect(claude_home, Path(tmp) / "out",
|
|
724
|
-
extra_args=["--skills-dir", str(skills_dir)],
|
|
725
|
-
sample="newest", max_sessions=2)
|
|
726
|
-
|
|
727
|
-
sampled_ids = {s["meta"]["id"] for s in inventory["sessions"] if s["sampled"]}
|
|
728
|
-
self.assertEqual(sampled_ids, {"newest-1", "newest-2"})
|
|
729
|
-
self.assertNotIn("older-skill-session", sampled_ids)
|
|
730
|
-
|
|
731
|
-
def test_skill_stratified_applies_per_skill_and_no_skill_caps(self):
|
|
732
|
-
# Pins the skill-stratified branch: skill-using sessions are drawn first (capped
|
|
733
|
-
# by --per-skill), then no-skill sessions fill the rest (capped by
|
|
734
|
-
# --no-skill), both newest-first within their own pool.
|
|
735
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
736
|
-
claude_home = Path(tmp) / "claude"
|
|
737
|
-
proj = claude_home / "projects" / "p1"
|
|
738
|
-
skills_dir = Path(tmp) / "skills-fixture"
|
|
739
|
-
write_skill_fixture(skills_dir, "alpha")
|
|
740
|
-
|
|
741
|
-
a1, a2 = proj / "a1.jsonl", proj / "a2.jsonl"
|
|
742
|
-
n1, n2, n3 = proj / "n1.jsonl", proj / "n2.jsonl", proj / "n3.jsonl"
|
|
743
|
-
write_claude_session_file(a1, "alpha-1", skill="alpha")
|
|
744
|
-
write_claude_session_file(a2, "alpha-2", skill="alpha")
|
|
745
|
-
write_claude_session_file(n1, "no-skill-1")
|
|
746
|
-
write_claude_session_file(n2, "no-skill-2")
|
|
747
|
-
write_claude_session_file(n3, "no-skill-3")
|
|
748
|
-
set_mtime(a1, 10)
|
|
749
|
-
set_mtime(a2, 20)
|
|
750
|
-
set_mtime(n1, 30)
|
|
751
|
-
set_mtime(n2, 40)
|
|
752
|
-
set_mtime(n3, 50)
|
|
753
|
-
|
|
754
|
-
inventory = run_collect(claude_home, Path(tmp) / "out",
|
|
755
|
-
extra_args=["--skills-dir", str(skills_dir)],
|
|
756
|
-
sample="skill-stratified", max_sessions=10,
|
|
757
|
-
per_skill=1, no_skill=2)
|
|
758
|
-
|
|
759
|
-
sampled_ids = {s["meta"]["id"] for s in inventory["sessions"] if s["sampled"]}
|
|
760
|
-
# per_skill=1 caps "alpha" at its newest instance only;
|
|
761
|
-
# no_skill=2 caps the no-skill pool at its two newest.
|
|
762
|
-
self.assertEqual(sampled_ids, {"alpha-1", "no-skill-1", "no-skill-2"})
|
|
763
|
-
self.assertNotIn("alpha-2", sampled_ids)
|
|
764
|
-
self.assertNotIn("no-skill-3", sampled_ids)
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
class HarnessValidationTests(unittest.TestCase):
|
|
768
|
-
def test_unknown_harness_is_rejected_by_argparse_cleanly(self):
|
|
769
|
-
captured_stderr = io.StringIO()
|
|
770
|
-
with mock.patch.object(sys, "argv", ["collect_sessions.py", "--harness", "bogus"]):
|
|
771
|
-
with contextlib.redirect_stderr(captured_stderr):
|
|
772
|
-
with self.assertRaises(SystemExit) as ctx:
|
|
773
|
-
cs.parse_args()
|
|
774
|
-
self.assertNotEqual(ctx.exception.code, 0)
|
|
775
|
-
self.assertNotIn("Traceback (most recent call last)", captured_stderr.getvalue())
|
|
776
|
-
self.assertIn("bogus", captured_stderr.getvalue())
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
if __name__ == "__main__":
|
|
780
|
-
unittest.main()
|