shikiru-cli 0.2.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.
Files changed (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,653 @@
1
+ """Headless agent contract checks, imported only by tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import sys
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from datetime import timedelta
13
+ from enum import StrEnum, auto
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, ClassVar
16
+
17
+ import pytest
18
+
19
+ from shikiru.agents.contracts import (
20
+ COST_BASIS_UNKNOWN,
21
+ AgentContext,
22
+ AgentSignal,
23
+ Compacted,
24
+ FailureKind,
25
+ Message,
26
+ SessionStarted,
27
+ ToolFinished,
28
+ ToolStarted,
29
+ TurnEnded,
30
+ TurnFailed,
31
+ TurnSpec,
32
+ Usage,
33
+ UsageReported,
34
+ )
35
+ from shikiru.agents.sdk.turn import (
36
+ StreamingTurn,
37
+ TurnParser,
38
+ TurnProcess,
39
+ TurnProcessResult,
40
+ TurnProcessSequence,
41
+ )
42
+ from shikiru.agents.testing.resume import RESUME_NONCE_PROMPT, RESUME_RECALL_PROMPT
43
+ from shikiru.clock import SystemClock
44
+ from shikiru.operator.probe import (
45
+ REPORT_COMMAND,
46
+ REPORT_INPUT_FILENAME,
47
+ init_scratch_repo,
48
+ live_report_problem,
49
+ prepare_live_report,
50
+ )
51
+ from shikiru.process import SubprocessRunner
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Callable, Mapping, Sequence
55
+
56
+ from pydantic import BaseModel
57
+
58
+ from shikiru.agents.contracts import Agent, AgentFactory
59
+
60
+ E2E_ENV_VAR = "SHIKIRU_E2E"
61
+ _TURN_TIMEOUT_SECONDS = 60.0
62
+ _COMMAND_FLAG = "-c"
63
+ _LIVE_COMPACTION_TIMEOUT_SECONDS = 900.0
64
+ _PYTEST_TIMEOUT_MARGIN_SECONDS = 60.0
65
+ _GROWTH_FILES = 6
66
+ _GROWTH_FILE_LINES = 400
67
+ _GROWTH_WORDS_PER_LINE = 10
68
+ _SYLLABLES = ("ka", "ro", "mit", "sel", "dun", "vo", "tra", "ple", "gor", "nis", "bu", "fex")
69
+ _SYLLABLES_PER_WORD = 3
70
+ _LIVE_COMPACTION_PROMPT = (
71
+ f"Read part1.txt through part{_GROWTH_FILES}.txt in this repository in order, each file "
72
+ "in full and one file per tool call. After each file, state its last line number in one "
73
+ "short sentence.\n"
74
+ "Then use Bash to run exactly this report command once:\n"
75
+ f'{REPORT_COMMAND} "$SHIKIRU_RUN_DIR/{REPORT_INPUT_FILENAME}"\n'
76
+ "After the command succeeds, reply with exactly: DONE followed by the nonce."
77
+ )
78
+ _LIVE_COMPACTION_TURN_PROMPT = (
79
+ "Remember this nonce: {nonce}. Then read {prompt_path} and follow it exactly."
80
+ )
81
+ _REPLAY_SCRIPT = """
82
+ import json, sys
83
+ from pathlib import Path
84
+ plan = json.loads(sys.argv[1])
85
+ for path, content in plan["writes"].items():
86
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
87
+ Path(path).write_text(content)
88
+ sys.stdout.write(Path(plan["stdout"]).read_text())
89
+ sys.stderr.write(Path(plan["stderr"]).read_text())
90
+ sys.exit(plan["exit_code"])
91
+ """
92
+
93
+
94
+ def _empty_session_logs() -> Mapping[str, Path]:
95
+ return {}
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class RecordedStream:
100
+ """One committed stdout/stderr capture and its normalized expected signals."""
101
+
102
+ name: str
103
+ stdout: Path
104
+ stderr: Path
105
+ exit_code: int
106
+ expected_signals: Sequence[AgentSignal]
107
+ session_id: str | None
108
+ session_logs: Mapping[str, Path] = field(default_factory=_empty_session_logs)
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class InvocationCase:
113
+ """One offline command and environment sequence for an agent turn."""
114
+
115
+ name: str
116
+ options: Mapping[str, Any]
117
+ spec: TurnSpec
118
+ expected_argv: Sequence[tuple[str, ...]]
119
+ expected_env: Sequence[Mapping[str, str]]
120
+ outcome_streams: Sequence[RecordedStream] = ()
121
+ expected_final_signals: Sequence[AgentSignal] = ()
122
+ interactive_session_id: str | None = None
123
+ expected_interactive_command: tuple[str, ...] | None = None
124
+
125
+
126
+ class CompactionCase(StrEnum):
127
+ """The in-turn compaction rule one offline scenario exercises (check 3)."""
128
+
129
+ THRESHOLD = auto()
130
+ CONTEXT_FAILURE = auto()
131
+ FAILED_RECOVERY = auto()
132
+
133
+
134
+ def _no_writes() -> Mapping[Path, str]:
135
+ return {}
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ReplayedProcess:
140
+ """One recorded process, plus the files its agent wrote while it ran (hook marker, log)."""
141
+
142
+ stream: RecordedStream
143
+ writes: Mapping[Path, str] = field(default_factory=_no_writes)
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class CompactionScenario:
148
+ """One turn replayed through the plugin's own process sequence and parsers."""
149
+
150
+ name: str
151
+ case: CompactionCase
152
+ options: Mapping[str, Any]
153
+ spec: TurnSpec
154
+ processes: Sequence[ReplayedProcess]
155
+ expected_argv: Sequence[tuple[str, ...]]
156
+
157
+
158
+ class AgentContractSuite:
159
+ """Pytest base class for the process-per-turn agent contract."""
160
+
161
+ runs_live_checks_in_default_gate: ClassVar[bool] = False
162
+
163
+ def agent_factory(self) -> AgentFactory:
164
+ """Return the plugin's `AgentFactory` entry point."""
165
+ raise NotImplementedError
166
+
167
+ def agent_options(self) -> Mapping[str, Any]:
168
+ """Return the `agents.<name>.options` used by the live checks."""
169
+ return {}
170
+
171
+ def build_spec(
172
+ self,
173
+ tmp_path: Path,
174
+ *,
175
+ resume_session_id: str | None,
176
+ usage: Usage | None = None,
177
+ model: str | None = None,
178
+ ) -> TurnSpec:
179
+ """Build a new or resumed `TurnSpec` for the plugin under test."""
180
+ raise NotImplementedError
181
+
182
+ def parser_for(self, sample: RecordedStream) -> TurnParser:
183
+ """Build a fresh parser for a recorded stream and its session-log excerpts."""
184
+ raise NotImplementedError
185
+
186
+ def recorded_streams(self) -> Sequence[RecordedStream]:
187
+ """Return committed stream captures and expected normalized signals."""
188
+ raise NotImplementedError
189
+
190
+ def error_streams(self) -> Sequence[RecordedStream]:
191
+ """Return recorded structured and unrecognized errors, including an `UNKNOWN` case."""
192
+ raise NotImplementedError
193
+
194
+ def invocation_cases(self, tmp_path: Path) -> Sequence[InvocationCase]:
195
+ """Return new, resume, model-change, environment and compaction cases."""
196
+ raise NotImplementedError
197
+
198
+ def process_sequence(self, agent: Agent, spec: TurnSpec) -> TurnProcessSequence:
199
+ """Return the same process sequence used by `agent.start_turn(spec)`."""
200
+ raise NotImplementedError
201
+
202
+ def compaction_scenarios(self, tmp_path: Path) -> Sequence[CompactionScenario]:
203
+ """Return recorded turns covering every `CompactionCase` (offline check 3)."""
204
+ raise NotImplementedError
205
+
206
+ def live_compaction_options(self) -> Mapping[str, Any]:
207
+ """Return options whose low `compact_at_pct` live check 6 grows past in one turn."""
208
+ raise NotImplementedError
209
+
210
+ def headless_ask_schema_model(self) -> type[BaseModel] | None:
211
+ """Return the result model for `headless().ask()`, when the plugin provides it."""
212
+ return None
213
+
214
+ def headless_ask_model(self) -> str:
215
+ """Return the model name passed to the live `headless().ask()` check."""
216
+ return "test-model"
217
+
218
+ def headless_ask_prompt(self) -> str:
219
+ """Return the prompt passed to the live `headless().ask()` check."""
220
+ return "ping"
221
+
222
+ def test_offline_replays_recorded_streams_and_obligations(self) -> None:
223
+ for sample in self.recorded_streams():
224
+ signals = _replay(self.parser_for(sample), sample)
225
+ _expect(signals == list(sample.expected_signals), sample.name)
226
+ assert_agent_obligations(signals, sample.session_id)
227
+
228
+ def test_offline_maps_errors_and_preserves_unknown_failures(self) -> None:
229
+ samples = self.error_streams()
230
+ _expect(bool(samples), "no error samples were provided")
231
+ unknown_found = False
232
+ for sample in samples:
233
+ signals = _replay(self.parser_for(sample), sample)
234
+ _expect(signals == list(sample.expected_signals), sample.name)
235
+ assert_agent_obligations(signals, sample.session_id)
236
+ failure = next(signal for signal in signals if isinstance(signal, TurnFailed))
237
+ unknown_found |= failure.kind is FailureKind.UNKNOWN
238
+ _expect(unknown_found, "unmapped error text did not produce UNKNOWN")
239
+
240
+ @pytest.mark.asyncio
241
+ async def test_offline_compaction_continues_within_one_turn(self, tmp_path: Path) -> None:
242
+ _expect(
243
+ any(
244
+ isinstance(signal, Compacted)
245
+ for sample in self.recorded_streams()
246
+ for signal in sample.expected_signals
247
+ ),
248
+ "recorded streams contain no Compacted signal",
249
+ )
250
+ scenarios = self.compaction_scenarios(tmp_path)
251
+ _expect(
252
+ {scenario.case for scenario in scenarios} == set(CompactionCase),
253
+ "compaction scenarios must cover every CompactionCase",
254
+ )
255
+ for scenario in scenarios:
256
+ signals, argv = await self._replay_scenario(scenario)
257
+ _expect(argv == list(scenario.expected_argv), scenario.name)
258
+ assert_agent_obligations(signals, scenario.spec.session_id)
259
+ _expect(_COMPACTION_RULES[scenario.case](signals), scenario.name)
260
+
261
+ async def _replay_scenario(
262
+ self, scenario: CompactionScenario
263
+ ) -> tuple[list[AgentSignal], list[tuple[str, ...]]]:
264
+ agent = self._build_agent(scenario.options)
265
+ replay = _ReplaySequence(self.process_sequence(agent, scenario.spec), scenario.processes)
266
+ turn = await StreamingTurn.start(scenario.spec, SubprocessRunner(), replay)
267
+ signals = [signal async for signal in turn.signals()]
268
+ _expect(replay.exhausted, f"{scenario.name}: the turn ended before every process ran")
269
+ return signals, replay.argv
270
+
271
+ def test_offline_builds_expected_argv_environment_and_interactive_commands(
272
+ self, tmp_path: Path
273
+ ) -> None:
274
+ for case in self.invocation_cases(tmp_path):
275
+ context = AgentContext(
276
+ options=case.options, runner=SubprocessRunner(), clock=SystemClock()
277
+ )
278
+ agent = self.agent_factory()(context)
279
+ sequence = self.process_sequence(agent, case.spec)
280
+ command: TurnProcess | None = sequence.start(case.spec)
281
+ commands: list[TurnProcess] = []
282
+ outcomes: list[TurnProcessResult] = []
283
+ streams = iter(case.outcome_streams)
284
+ while command is not None:
285
+ commands.append(command)
286
+ sample = next(streams, None)
287
+ if sample is None:
288
+ break
289
+ outcomes.append(_process_outcome(command.parser, sample))
290
+ command = sequence.advance(case.spec, outcomes[-1])
291
+ _expect([item.argv for item in commands] == list(case.expected_argv), case.name)
292
+ _expect(len(commands) == len(case.expected_env), case.name)
293
+ for command, expected in zip(commands, case.expected_env, strict=True):
294
+ _expect(
295
+ all(command.env.get(key) == value for key, value in expected.items()), case.name
296
+ )
297
+ if case.expected_final_signals:
298
+ _expect(bool(outcomes), case.name)
299
+ final_outcome = outcomes[-1]
300
+ final_terminals = [
301
+ signal
302
+ for signal in final_outcome.signals
303
+ if isinstance(signal, (TurnEnded, TurnFailed))
304
+ ]
305
+ expected_terminals = [
306
+ signal
307
+ for signal in case.expected_final_signals
308
+ if isinstance(signal, (TurnEnded, TurnFailed))
309
+ ]
310
+ _expect(final_terminals == expected_terminals, case.name)
311
+ assert_agent_obligations(case.expected_final_signals, case.spec.session_id)
312
+ if case.interactive_session_id is not None:
313
+ _expect(case.expected_interactive_command is not None, case.name)
314
+ restarted = self.agent_factory()(context)
315
+ _expect(
316
+ tuple(
317
+ restarted.interactive_command(
318
+ case.interactive_session_id, case.spec.run_dir, case.spec.rung
319
+ )
320
+ )
321
+ == case.expected_interactive_command,
322
+ case.name,
323
+ )
324
+
325
+ @pytest.mark.asyncio
326
+ async def test_live_first_turn_writes_a_valid_report(self, tmp_path: Path) -> None:
327
+ self._require_live()
328
+ spec = await self._prepare_live_report_spec(
329
+ self.build_spec(tmp_path, resume_session_id=None)
330
+ )
331
+ signals = await self._run_turn(spec)
332
+ problem = await asyncio.to_thread(live_report_problem, spec, signals)
333
+ _expect(problem is None, problem or "")
334
+
335
+ async def _prepare_live_report_spec(self, spec: TurnSpec) -> TurnSpec:
336
+ prompt_path = await prepare_live_report(SubprocessRunner(), spec)
337
+ return _with_prompt(spec, str(prompt_path))
338
+
339
+ @pytest.mark.asyncio
340
+ async def test_live_usage_reports_session_tokens(self, tmp_path: Path) -> None:
341
+ self._require_live()
342
+ spec = self.build_spec(tmp_path, resume_session_id=None)
343
+ await init_scratch_repo(SubprocessRunner(), spec.cwd)
344
+ signals = await self._run_turn(spec)
345
+ usage = [signal.usage for signal in signals if isinstance(signal, UsageReported)]
346
+ _expect(bool(usage), "live turn reported no usage")
347
+ _expect(usage[-1].input_tokens + usage[-1].output_tokens > 0, "live token usage was zero")
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_live_model_change_resumes_and_recalls_session_memory(
351
+ self, tmp_path: Path
352
+ ) -> None:
353
+ self._require_live()
354
+ first = self.build_spec(tmp_path, resume_session_id=None)
355
+ await init_scratch_repo(SubprocessRunner(), first.cwd)
356
+ nonce = uuid.uuid4().hex
357
+ first_signals = await self._run_turn(
358
+ _with_prompt(first, RESUME_NONCE_PROMPT.format(nonce=nonce))
359
+ )
360
+ session_id, usage = _latest_usage(first_signals)
361
+ resumed = self.build_spec(
362
+ tmp_path,
363
+ resume_session_id=session_id,
364
+ usage=usage,
365
+ model=f"{first.rung.model}-changed",
366
+ )
367
+ signals = await self._run_turn(_with_prompt(resumed, RESUME_RECALL_PROMPT))
368
+ message = "\n".join(signal.text for signal in signals if isinstance(signal, Message))
369
+ _expect(nonce in message, "resumed turn did not recall the nonce")
370
+
371
+ @pytest.mark.asyncio
372
+ async def test_live_stop_leaves_session_resumable(self, tmp_path: Path) -> None:
373
+ self._require_live()
374
+ spec = self.build_spec(tmp_path, resume_session_id=None)
375
+ await init_scratch_repo(SubprocessRunner(), spec.cwd)
376
+ agent = self._build_agent()
377
+ turn = await agent.start_turn(_with_prompt(spec, "wait for the next instruction"))
378
+ signals = turn.signals()
379
+ started = await asyncio.wait_for(signals.__anext__(), _TURN_TIMEOUT_SECONDS)
380
+ if not isinstance(started, SessionStarted):
381
+ message = "live turn did not report a resumable session id"
382
+ raise TypeError(message)
383
+ await turn.stop()
384
+ resumed = self.build_spec(tmp_path, resume_session_id=started.session_id)
385
+ resumed_signals = await self._run_turn(resumed)
386
+ _expect(isinstance(resumed_signals[-1], TurnEnded), "resumed turn did not end")
387
+
388
+ @pytest.mark.asyncio
389
+ async def test_live_headless_ask_returns_schema_valid_json(self, tmp_path: Path) -> None:
390
+ self._require_live()
391
+ schema_model = self.headless_ask_schema_model()
392
+ if schema_model is None:
393
+ pytest.skip("plugin does not provide HeadlessAgent")
394
+ spec = self.build_spec(tmp_path, resume_session_id=None)
395
+ agent = self._build_agent()
396
+ headless = agent.headless()
397
+ if headless is None:
398
+ pytest.skip("plugin does not provide HeadlessAgent")
399
+ result = await headless.ask(
400
+ self.headless_ask_prompt(),
401
+ schema_model.model_json_schema(),
402
+ self.headless_ask_model(),
403
+ spec.rung.effort,
404
+ spec.cwd,
405
+ timedelta(seconds=_TURN_TIMEOUT_SECONDS),
406
+ )
407
+ schema_model.model_validate(result)
408
+
409
+ @pytest.mark.asyncio
410
+ @pytest.mark.timeout(_LIVE_COMPACTION_TIMEOUT_SECONDS + _PYTEST_TIMEOUT_MARGIN_SECONDS)
411
+ async def test_live_turn_compacts_as_it_grows_and_completes(self, tmp_path: Path) -> None:
412
+ self._require_live()
413
+ spec = await self._prepare_live_report_spec(
414
+ self.build_spec(tmp_path, resume_session_id=None)
415
+ )
416
+ prompt_path = Path(spec.prompt)
417
+ await asyncio.to_thread(_write_growth_files, spec.cwd)
418
+ await asyncio.to_thread(prompt_path.write_text, _LIVE_COMPACTION_PROMPT)
419
+ nonce = uuid.uuid4().hex
420
+ spec = _with_prompt(
421
+ spec, _LIVE_COMPACTION_TURN_PROMPT.format(nonce=nonce, prompt_path=prompt_path)
422
+ )
423
+ signals = await self._run_turn(
424
+ spec, self.live_compaction_options(), _LIVE_COMPACTION_TIMEOUT_SECONDS
425
+ )
426
+ problem = await asyncio.to_thread(live_report_problem, spec, signals)
427
+ _expect(problem is None, problem or "")
428
+ _expect(
429
+ any(isinstance(signal, Compacted) for signal in signals), "the turn never compacted"
430
+ )
431
+ message = "\n".join(signal.text for signal in signals if isinstance(signal, Message))
432
+ _expect(nonce in message, "the compacted turn forgot the nonce")
433
+
434
+ def _require_live(self) -> None:
435
+ if not self.runs_live_checks_in_default_gate and not os.environ.get(E2E_ENV_VAR):
436
+ pytest.skip(f"set {E2E_ENV_VAR}=1 to run live agent contract checks")
437
+
438
+ def _build_agent(self, options: Mapping[str, Any] | None = None) -> Agent:
439
+ return self.agent_factory()(
440
+ AgentContext(
441
+ options=self.agent_options() if options is None else options,
442
+ runner=SubprocessRunner(),
443
+ clock=SystemClock(),
444
+ )
445
+ )
446
+
447
+ async def _run_turn(
448
+ self,
449
+ spec: TurnSpec,
450
+ options: Mapping[str, Any] | None = None,
451
+ timeout: float = _TURN_TIMEOUT_SECONDS,
452
+ ) -> list[AgentSignal]:
453
+ turn = await self._build_agent(options).start_turn(spec)
454
+ async with asyncio.timeout(timeout):
455
+ return [signal async for signal in turn.signals()]
456
+
457
+
458
+ def _replay(parser: TurnParser, sample: RecordedStream) -> list[AgentSignal]:
459
+ signals: list[AgentSignal] = []
460
+ for line in sample.stdout.read_text().splitlines():
461
+ signals.extend(parser.feed(line))
462
+ signals.extend(parser.finish(sample.exit_code, sample.stderr.read_text()))
463
+ return signals
464
+
465
+
466
+ def _process_outcome(parser: TurnParser, sample: RecordedStream) -> TurnProcessResult:
467
+ return TurnProcessResult(
468
+ signals=tuple(_replay(parser, sample)),
469
+ exit_code=sample.exit_code,
470
+ stderr=sample.stderr.read_text(),
471
+ )
472
+
473
+
474
+ def assert_agent_obligations(signals: Sequence[AgentSignal], session_id: str | None) -> None:
475
+ """Validate the stream-order, usage and tool-call obligations."""
476
+ _validate_session_start(signals, session_id)
477
+ _validate_terminal(signals)
478
+ _validate_tool_sequences(signals)
479
+ _validate_usage_sequences(signals)
480
+
481
+
482
+ def _validate_session_start(signals: Sequence[AgentSignal], session_id: str | None) -> None:
483
+ starts = [
484
+ (index, signal)
485
+ for index, signal in enumerate(signals)
486
+ if isinstance(signal, SessionStarted)
487
+ ]
488
+ _expect(len(starts) == 1, "turn must emit one SessionStarted")
489
+ _expect(starts[0][0] == 0, "SessionStarted must be the first signal")
490
+ started = starts[0][1]
491
+ _expect(bool(started.session_id), "SessionStarted must carry a session id")
492
+ if session_id is not None:
493
+ _expect(started.session_id == session_id, "resumed turn changed its session id")
494
+
495
+
496
+ def _validate_terminal(signals: Sequence[AgentSignal]) -> None:
497
+ terminals = [signal for signal in signals if isinstance(signal, (TurnEnded, TurnFailed))]
498
+ _expect(len(terminals) == 1, "turn must emit one terminal signal")
499
+ _expect(signals[-1] == terminals[0], "terminal signal must be last")
500
+
501
+
502
+ def _validate_tool_sequences(signals: Sequence[AgentSignal]) -> None:
503
+ running_tools: set[str] = set()
504
+ for signal in signals:
505
+ if isinstance(signal, ToolStarted):
506
+ _expect(signal.name not in running_tools, "tool started while already running")
507
+ running_tools.add(signal.name)
508
+ elif isinstance(signal, ToolFinished):
509
+ _expect(signal.name in running_tools, "tool finished without a matching start")
510
+ running_tools.remove(signal.name)
511
+ _expect(not running_tools, "turn ended while a tool was still running")
512
+
513
+
514
+ def _validate_usage_sequences(signals: Sequence[AgentSignal]) -> None:
515
+ last_usage: Usage | None = None
516
+ compacted_since_usage = False
517
+ for signal in signals:
518
+ if isinstance(signal, Compacted):
519
+ compacted_since_usage = True
520
+ elif isinstance(signal, UsageReported):
521
+ if last_usage is not None:
522
+ _expect(
523
+ signal.usage.input_tokens >= last_usage.input_tokens, "input tokens decreased"
524
+ )
525
+ _expect(
526
+ signal.usage.output_tokens >= last_usage.output_tokens,
527
+ "output tokens decreased",
528
+ )
529
+ _expect(
530
+ signal.usage.cache_read_tokens >= last_usage.cache_read_tokens,
531
+ "cache-read tokens decreased",
532
+ )
533
+ _expect(
534
+ signal.usage.cache_write_tokens >= last_usage.cache_write_tokens,
535
+ "cache-write tokens decreased",
536
+ )
537
+ if (
538
+ compacted_since_usage
539
+ and last_usage.context_used_pct is not None
540
+ and signal.usage.context_used_pct is not None
541
+ ):
542
+ _expect(
543
+ signal.usage.context_used_pct < last_usage.context_used_pct,
544
+ "context use did not drop after compaction",
545
+ )
546
+ if signal.usage.cost_usd is None:
547
+ _expect(
548
+ signal.usage.cost_basis == COST_BASIS_UNKNOWN, "unpriced usage must say unknown"
549
+ )
550
+ else:
551
+ _expect(
552
+ signal.usage.cost_basis != COST_BASIS_UNKNOWN, "priced usage needs a cost basis"
553
+ )
554
+ last_usage = signal.usage
555
+ compacted_since_usage = False
556
+
557
+
558
+ def _expect(condition: bool, message: str) -> None:
559
+ if not condition:
560
+ raise AssertionError(message)
561
+
562
+
563
+ def _with_prompt(spec: TurnSpec, prompt: str) -> TurnSpec:
564
+ return TurnSpec(
565
+ rung=spec.rung,
566
+ role=spec.role,
567
+ prompt=prompt,
568
+ cwd=spec.cwd,
569
+ run_dir=spec.run_dir,
570
+ turn_dir=spec.turn_dir,
571
+ add_dirs=spec.add_dirs,
572
+ env=spec.env,
573
+ session_id=spec.session_id,
574
+ usage=spec.usage,
575
+ )
576
+
577
+
578
+ def _latest_usage(signals: Sequence[AgentSignal]) -> tuple[str, Usage]:
579
+ session = next(signal for signal in signals if isinstance(signal, SessionStarted))
580
+ usage = [signal.usage for signal in signals if isinstance(signal, UsageReported)]
581
+ _expect(bool(usage), "live turn reported no usage")
582
+ return session.session_id, usage[-1]
583
+
584
+
585
+ def _ended(signals: Sequence[AgentSignal]) -> bool:
586
+ return isinstance(signals[-1], TurnEnded)
587
+
588
+
589
+ _COMPACTION_RULES: dict[CompactionCase, Callable[[Sequence[AgentSignal]], bool]] = {
590
+ CompactionCase.THRESHOLD: lambda signals: (
591
+ _ended(signals) and sum(isinstance(signal, Compacted) for signal in signals) > 1
592
+ ),
593
+ CompactionCase.CONTEXT_FAILURE: lambda signals: (
594
+ _ended(signals) and any(isinstance(signal, Compacted) for signal in signals)
595
+ ),
596
+ CompactionCase.FAILED_RECOVERY: lambda signals: (
597
+ isinstance(signals[-1], TurnFailed) and signals[-1].kind is FailureKind.OTHER
598
+ ),
599
+ }
600
+
601
+
602
+ class _ReplaySequence:
603
+ """Runs the plugin's sequence, replacing each process with its recorded output."""
604
+
605
+ def __init__(self, inner: TurnProcessSequence, processes: Sequence[ReplayedProcess]) -> None:
606
+ self._inner = inner
607
+ self._processes = list(processes)
608
+ self._next = 0
609
+ self.argv: list[tuple[str, ...]] = []
610
+
611
+ @property
612
+ def exhausted(self) -> bool:
613
+ return self._next == len(self._processes)
614
+
615
+ def start(self, spec: TurnSpec) -> TurnProcess:
616
+ return self._replay(self._inner.start(spec))
617
+
618
+ def advance(self, spec: TurnSpec, previous: TurnProcessResult) -> TurnProcess | None:
619
+ process = self._inner.advance(spec, previous)
620
+ return None if process is None else self._replay(process)
621
+
622
+ def _replay(self, process: TurnProcess) -> TurnProcess:
623
+ _expect(self._next < len(self._processes), f"unrecorded process {process.argv}")
624
+ recorded = self._processes[self._next]
625
+ self._next += 1
626
+ self.argv.append(process.argv)
627
+ plan = {
628
+ "writes": {str(path): content for path, content in recorded.writes.items()},
629
+ "stdout": str(recorded.stream.stdout),
630
+ "stderr": str(recorded.stream.stderr),
631
+ "exit_code": recorded.stream.exit_code,
632
+ }
633
+ return TurnProcess(
634
+ argv=(sys.executable, _COMMAND_FLAG, _REPLAY_SCRIPT, json.dumps(plan)),
635
+ cwd=process.cwd,
636
+ env=process.env,
637
+ parser=process.parser,
638
+ )
639
+
640
+
641
+ def _write_growth_files(repo: Path) -> None:
642
+ for number in range(1, _GROWTH_FILES + 1):
643
+ lines = (
644
+ f"{number}-{line:04d} "
645
+ + " ".join(_growth_word(number, line, word) for word in range(_GROWTH_WORDS_PER_LINE))
646
+ for line in range(1, _GROWTH_FILE_LINES + 1)
647
+ )
648
+ (repo / f"part{number}.txt").write_text("\n".join(lines) + "\n")
649
+
650
+
651
+ def _growth_word(*key: int) -> str:
652
+ digest = hashlib.blake2b(repr(key).encode(), digest_size=_SYLLABLES_PER_WORD).digest()
653
+ return "".join(_SYLLABLES[byte % len(_SYLLABLES)] for byte in digest)
@@ -0,0 +1,15 @@
1
+ """The resume-recall protocol exercised by `AgentContractSuite`'s live check 4.
2
+
3
+ A plugin whose agent has no memory of its own (only the fake agent needs this) must answer
4
+ `RESUME_RECALL_PROMPT` with a message that contains whatever nonce it was given via
5
+ `RESUME_NONCE_PROMPT` earlier in the *same* session, recovered from state tied to that
6
+ session's id rather than to the pane or run directory alone. A real, conversational agent
7
+ needs no special handling: it answers from genuine memory.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ RESUME_NONCE_PROMPT = "Remember this nonce for later in this session: {nonce}"
13
+ RESUME_RECALL_PROMPT = (
14
+ "What nonce did I ask you to remember earlier in this session? Reply with only the nonce."
15
+ )