Skip to content

Commit 42993e4

Browse files
Merge pull request #179 from EvanProgramming/codex/issue-173-e2e-gate
test: gate the complete plan-to-deployment workflow
2 parents 6b821ba + c9f837d commit 42993e4

8 files changed

Lines changed: 226 additions & 13 deletions

‎Makefile‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: install install-core run clean lint test test-core tui-test check docs-check shell-check benchmark wheel-smoke docker-smoke git-status git-diff git-log web
1+
.PHONY: install install-core run clean lint test test-core tui-test check docs-check shell-check benchmark agent-acceptance wheel-smoke docker-smoke git-status git-diff git-log web
22

33
# Tests parse the benchmark target's stdout as JSON; do not inject GNU make's
44
# recursive directory banners into that machine-readable output.
@@ -64,7 +64,7 @@ clean:
6464

6565
# Syntax check
6666
lint:
67-
$(VENV_PYTHON) -m compileall -q main.py tui_launcher.py tui_backend.py main_debug.py server.py tools.py memory.py event_store.py interaction.py task_engine.py learning_engine.py learning_benchmark.py learning_worker.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py project_graph.py github_cli.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py
67+
$(VENV_PYTHON) -m compileall -q main.py tui_launcher.py tui_backend.py main_debug.py server.py tools.py memory.py event_store.py interaction.py task_engine.py learning_engine.py learning_benchmark.py learning_worker.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py project_graph.py github_cli.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/agent_workflow_acceptance.py scripts/wheel_smoke.py
6868
@echo "Python syntax OK."
6969
@echo "All files pass syntax check."
7070

@@ -91,6 +91,9 @@ wheel-smoke:
9191
$(VENV_PYTHON) -m pip install build -q
9292
$(VENV_PYTHON) scripts/wheel_smoke.py
9393

94+
agent-acceptance:
95+
$(VENV_PYTHON) scripts/agent_workflow_acceptance.py
96+
9497
docs-check:
9598
$(VENV_PYTHON) scripts/generate_tool_inventory.py --check
9699
$(VENV_PYTHON) scripts/check_docs.py
@@ -107,7 +110,7 @@ docker-smoke:
107110
# Quick verification
108111
check:
109112
@echo "Checking Python syntax..."
110-
@$(VENV_PYTHON) -m py_compile main.py tui_launcher.py tui_backend.py main_debug.py server.py tools.py memory.py event_store.py interaction.py task_engine.py learning_engine.py learning_benchmark.py learning_worker.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py project_graph.py github_cli.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py
113+
@$(VENV_PYTHON) -m py_compile main.py tui_launcher.py tui_backend.py main_debug.py server.py tools.py memory.py event_store.py interaction.py task_engine.py learning_engine.py learning_benchmark.py learning_worker.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py project_graph.py github_cli.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/agent_workflow_acceptance.py scripts/wheel_smoke.py
111114
@echo " Python modules: OK"
112115
@echo "Checking git tools..."
113116
@$(VENV_PYTHON) -c "from tools import AVAILABLE_TOOLS; git = [k for k in AVAILABLE_TOOLS if k.startswith('git_')]; print(f' {len(git)} git tools, {len(AVAILABLE_TOOLS)} total tools')"

‎docs/agentic-runtime-roadmap.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,24 @@ is intentionally deferred rather than hidden behind speculative abstractions:
2121

2222
MCP remains intentionally non-interactive. Tool approvals remain distinct from
2323
clarification questions on every future surface.
24+
25+
## Release acceptance
26+
27+
Run `make agent-acceptance` for the deterministic plan-to-deployment workflow.
28+
`make wheel-smoke` repeats the same check against a newly installed wheel from
29+
outside the checkout. It verifies plan approval, Agent-mode execution, durable
30+
receipts, generated files, and a real loopback HTTP deployment without using a
31+
provider key.
32+
33+
Live-provider validation is optional and must be reported as unverified unless
34+
it is actually run. To exercise it manually, create a temporary project and
35+
state database, then run the installed UI with the configured provider:
36+
37+
```bash
38+
work_dir="$(mktemp -d)"
39+
mkdir "$work_dir/project"
40+
KYROZEN_DB_PATH="$work_dir/state.sqlite3" kyrozen --project "$work_dir/project"
41+
```
42+
43+
Select Plan mode, request a small locally served page, accept the proposal, and
44+
confirm the task receipts and deployment check before reporting a live pass.

‎docs/self-evolution.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ diagnostic only and is not treated as a release claim.
238238

239239
## Verification snapshot commands
240240

241-
Current repository test count at this snapshot: **233 unittest cases**.
241+
Current repository test count at this snapshot: **239 unittest cases**.
242242

243243
The post-#54 snapshot ran the repository's current checks and smoke coverage:
244244

@@ -275,7 +275,7 @@ git diff --check
275275
```
276276

277277
The historical post-#54 verification passed the 130 discovered tests; the current
278-
repository contains 233 discovered tests. The historical run also covered the API
278+
repository contains 239 discovered tests. The historical run also covered the API
279279
health/scoping smoke, the CLI command-loop smoke, and the five-case
280280
clean/evolved benchmark described above. `make check` reports the live 39-tool
281281
runtime inventory, including 14 `git_` tools. A new artifact is not immediate:
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
"""Deterministic plan-to-deployment acceptance test for the installed runtime."""
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import os
8+
import socket
9+
import sys
10+
import tempfile
11+
from pathlib import Path
12+
from unittest.mock import patch
13+
14+
if os.environ.get("KYROZEN_ACCEPTANCE_INSTALLED") != "1":
15+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16+
17+
import main
18+
from capability_tokens import issue_capability_token
19+
from event_store import EventStore
20+
from interaction import InteractionController
21+
from task_engine import TaskManager
22+
23+
24+
class _LearningStub:
25+
def feedback_signal(self, _text):
26+
return None
27+
28+
def route_profile(self, _text, _profile=None):
29+
return "coder"
30+
31+
def begin_run(self, profile, _task, provider_model=None):
32+
return {"run_id": "agent-acceptance", "profile": profile, "provider_model": provider_model}
33+
34+
def artifact_context(self, _run):
35+
return "", []
36+
37+
38+
class _RuntimeStub:
39+
def turn_start(self, **_kwargs):
40+
return None
41+
42+
def turn_end(self, **_kwargs):
43+
return None
44+
45+
46+
def _free_port() -> int:
47+
with socket.socket() as sock:
48+
sock.bind(("127.0.0.1", 0))
49+
return int(sock.getsockname()[1])
50+
51+
52+
def main_acceptance() -> None:
53+
port = _free_port()
54+
page = "<!doctype html><title>OpenKyrozen acceptance</title><h1>deployed</h1>"
55+
deployment_test = f'''import http.server
56+
import threading
57+
import unittest
58+
import urllib.request
59+
60+
61+
class DeploymentTest(unittest.TestCase):
62+
def test_page_is_served(self):
63+
handler = type("QuietHandler", (http.server.SimpleHTTPRequestHandler,), {{
64+
"log_message": lambda *args: None,
65+
}})
66+
server = http.server.ThreadingHTTPServer(("127.0.0.1", {port}), handler)
67+
thread = threading.Thread(target=server.serve_forever)
68+
thread.start()
69+
try:
70+
with urllib.request.urlopen("http://127.0.0.1:{port}/index.html", timeout=5) as response:
71+
self.assertEqual(response.status, 200)
72+
self.assertIn(b"deployed", response.read())
73+
finally:
74+
server.shutdown()
75+
server.server_close()
76+
thread.join(timeout=5)
77+
78+
79+
if __name__ == "__main__":
80+
unittest.main()
81+
'''
82+
plan = {
83+
"mode": "plan",
84+
"plan_name": "Deploy local acceptance page",
85+
"overview": "Create a page and verify its local HTTP deployment.",
86+
"do_not_execute_until_approved": True,
87+
"steps": [
88+
{"step_id": "page", "title": "Create index.html", "details": "Write index.html.",
89+
"acceptance_criteria": ["index.html exists"]},
90+
{"step_id": "test", "title": "Create test_deploy.py", "details": "Write test_deploy.py.",
91+
"acceptance_criteria": ["test_deploy.py exists"]},
92+
{"step_id": "verify", "title": "Verify local deployment",
93+
"details": "Run python -m unittest test_deploy.py -v.",
94+
"acceptance_criteria": ["unittest exits successfully"]},
95+
],
96+
}
97+
responses = [
98+
json.dumps(plan),
99+
"Action: " + json.dumps({"action": "write_file", "args": f"index.html|{page}"}),
100+
"TaskDone: 0\nAction: " + json.dumps(
101+
{"action": "write_file", "args": f"test_deploy.py|{deployment_test}"}
102+
),
103+
"TaskDone: 1\nAction: " + json.dumps(
104+
{"action": "run_cmd", "args": f"{sys.executable} -m unittest test_deploy.py -v"}
105+
),
106+
"TaskDone: 2\nDeployment verified.",
107+
]
108+
109+
original = (
110+
main.tasks, main._interaction_controller, main.learning_engine,
111+
main._get_workspace_root(), main._execution_capability_token,
112+
)
113+
with tempfile.TemporaryDirectory(prefix="openkyrozen-agent-acceptance-") as directory:
114+
root = Path(directory)
115+
store = EventStore(root / "state.sqlite3")
116+
main.tasks = TaskManager(store, workspace_id="acceptance", session_id="release")
117+
main._interaction_controller = InteractionController(
118+
store, workspace_id="acceptance", session_id="release",
119+
)
120+
main._interaction_controller.set_mode("plan")
121+
main.learning_engine = _LearningStub()
122+
main._set_workspace_root(root)
123+
main._execution_capability_token = issue_capability_token(
124+
"agent-acceptance", frozenset({"read", "write", "shell"}),
125+
)
126+
events = []
127+
stream_token = main._stream_event_callback.set(events.append)
128+
approval_token = main._approval_callback.set(lambda _action, _args: True)
129+
try:
130+
with patch.object(main, "_plugin_runtime_for_surface", return_value=_RuntimeStub()), \
131+
patch.object(main, "_touch_detached_learning_heartbeat"), \
132+
patch.object(main, "dispatch_learning_cycle"), \
133+
patch.object(main, "_summarize_old_turns"), \
134+
patch.object(main, "_build_messages", return_value=[]), \
135+
patch.object(main, "_build_memory_context", return_value=""), \
136+
patch.object(main, "_classify_complexity", return_value="simple"), \
137+
patch.object(main, "_call_llm_with_spinner", side_effect=responses), \
138+
patch.object(main, "_get_llm_response", return_value=""), \
139+
patch.object(main, "_finish_learning_run", side_effect=lambda run, receipts, task,
140+
result, records, tokens, started: result):
141+
proposal = main._chat_turn("Create and deploy a local acceptance page")
142+
assert "Deploy local acceptance page (v1)" in proposal, proposal
143+
reply = main._chat_turn("accept plan")
144+
145+
assert "Deployment verified" in reply, reply
146+
assert [task["status"] for task in main.tasks.tasks] == ["succeeded"] * 3
147+
receipts = store.list_events(
148+
"execution.receipt", workspace_id="acceptance", session_id="release",
149+
)
150+
assert [event["payload"]["action"] for event in reversed(receipts)] == [
151+
"write_file", "write_file", "run_cmd",
152+
]
153+
state = main._interaction_controller.state()
154+
assert state["executing_plan"] is None
155+
assert state["preference_mode"] == "plan"
156+
assert state["effective_mode"] == "plan"
157+
assert any(
158+
event.get("event") == "interaction"
159+
and event.get("interaction", {}).get("effective_mode") == "agent"
160+
for event in events
161+
)
162+
assert (root / "index.html").read_text(encoding="utf-8") == page
163+
assert (root / "test_deploy.py").is_file()
164+
with socket.socket() as sock:
165+
assert sock.connect_ex(("127.0.0.1", port)) != 0
166+
finally:
167+
main._approval_callback.reset(approval_token)
168+
main._stream_event_callback.reset(stream_token)
169+
(main.tasks, main._interaction_controller, main.learning_engine,
170+
previous_root, main._execution_capability_token) = original
171+
main._set_workspace_root(previous_root)
172+
173+
174+
if __name__ == "__main__":
175+
main_acceptance()
176+
print("Agent workflow acceptance passed.")

‎scripts/wheel_smoke.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ def main() -> int:
112112
if "installed artifact auth/task behavior passed" not in probe.stdout:
113113
raise RuntimeError(f"installed artifact behavior probe failed:\n{probe.stdout}{probe.stderr}")
114114

115+
acceptance_env = env.copy()
116+
acceptance_env["KYROZEN_ACCEPTANCE_INSTALLED"] = "1"
117+
acceptance = _run(
118+
[str(python), str(ROOT / "scripts" / "agent_workflow_acceptance.py")],
119+
cwd=caller, env=acceptance_env,
120+
)
121+
if "Agent workflow acceptance passed." not in acceptance.stdout:
122+
raise RuntimeError(f"installed agent workflow acceptance failed:\n{acceptance.stdout}{acceptance.stderr}")
123+
115124
print("Wheel installation smoke passed: kyrozen ran from an unrelated directory.")
116125
return 0
117126

‎server.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ def _task_worker(session_id: str | None) -> TaskWorker:
231231

232232
def _task_scopes(statuses: set[str]) -> set[str | None]:
233233
rows = _agent.memory_bank.store.list_tasks(
234-
workspace_id=_agent.memory_bank.workspace_id,
234+
workspace_id=_agent.interaction_workspace_id(),
235235
statuses=statuses,
236236
limit=10000,
237237
user_id=_SERVER_ACTOR_ID,
@@ -1376,7 +1376,7 @@ async def api_v2_create_task(request: Request):
13761376
@app.post("/api/v2/tasks/{task_id}/resume", dependencies=[Depends(require_api_access)])
13771377
async def api_v2_resume_task(task_id: str):
13781378
candidates = _agent.memory_bank.store.list_tasks(
1379-
workspace_id=_agent.memory_bank.workspace_id,
1379+
workspace_id=_agent.interaction_workspace_id(),
13801380
statuses={"pending", "running", "failed", "blocked"},
13811381
limit=10000,
13821382
user_id=_SERVER_ACTOR_ID,
@@ -1504,7 +1504,7 @@ async def api_v2_events(event_type: str | None = None, session_id: str | None =
15041504
session = _normalise_session_id(session_id) if session_id else None
15051505
return {"events": _agent.memory_bank.store.list_events(
15061506
event_type=event_type, limit=max(1, min(limit, 500)),
1507-
workspace_id=_agent.memory_bank.workspace_id, session_id=session, user_id=_SERVER_ACTOR_ID,
1507+
workspace_id=_agent.interaction_workspace_id(), session_id=session, user_id=_SERVER_ACTOR_ID,
15081508
)}
15091509

15101510

‎tests/test_durable_tasks_gateway.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from event_store import EventStore
1414
from task_engine import TaskManager
15+
from workspace_context import source_scope_id
1516

1617

1718
class DurableTaskGatewayTests(unittest.TestCase):
@@ -90,16 +91,17 @@ def test_gateway_recovers_scoped_running_task_and_executes_api_task(self):
9091
db_path = root / "state.sqlite3"
9192
skills_path = root / "skills"
9293
store = EventStore(db_path)
94+
workspace_id = source_scope_id(workspace)
9395
recovery_manager = TaskManager(
94-
store, user_id="local", workspace_id="default", session_id="restart-session"
96+
store, user_id="local", workspace_id=workspace_id, session_id="restart-session"
9597
)
9698
recovery_index = recovery_manager.add_task(
9799
"recover a running file task",
98100
checkpoint={"action": "write_file", "args": "recovered.txt|after restart"},
99101
)
100102
recovery_manager.set_status(recovery_index, "running")
101103
command_manager = TaskManager(
102-
store, user_id="local", workspace_id="default", session_id="restart-command-session"
104+
store, user_id="local", workspace_id=workspace_id, session_id="restart-command-session"
103105
)
104106
command_index = command_manager.add_task(
105107
"fail a recovered command task",
@@ -110,7 +112,7 @@ def test_gateway_recovers_scoped_running_task_and_executes_api_task(self):
110112
)
111113
command_manager.set_status(command_index, "running")
112114
failed_manager = TaskManager(
113-
store, user_id="local", workspace_id="default", session_id="resume-session"
115+
store, user_id="local", workspace_id=workspace_id, session_id="resume-session"
114116
)
115117
failed_index = failed_manager.add_task("resume only when requested")
116118
failed_manager.set_status(failed_index, "failed")

‎tests/test_web_sessions.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pathlib import Path
1212

1313
from event_store import EventStore
14+
from workspace_context import source_scope_id
1415

1516

1617
class WebSessionBrowserTests(unittest.TestCase):
@@ -79,6 +80,7 @@ def test_browser_restores_switches_and_retains_durable_sessions_after_restart(se
7980
(workspace / "home").mkdir()
8081
db_path = root / "state.sqlite3"
8182
store = EventStore(db_path)
83+
interaction_workspace_id = source_scope_id(workspace)
8284
for session_id, messages in {
8385
"session-one": [("user", "first user"), ("assistant", "UI_你好_✅")],
8486
"session-two": [("user", "second user"), ("assistant", "Second_会话_✅")],
@@ -98,7 +100,7 @@ def test_browser_restores_switches_and_retains_durable_sessions_after_restart(se
98100
{"id": "all", "label": "All", "description": "Cover every surface"},
99101
],
100102
}],
101-
}, user_id="local", workspace_id="default", session_id="session-one",
103+
}, user_id="local", workspace_id=interaction_workspace_id, session_id="session-one",
102104
)
103105

104106
port = self._free_port()
@@ -139,7 +141,7 @@ def test_browser_restores_switches_and_retains_durable_sessions_after_restart(se
139141
"id": "step-1", "title": "Verify controls",
140142
"description": "Use the browser UI.", "acceptance": ["Controls respond"],
141143
}],
142-
}, user_id="local", workspace_id="default", session_id="session-one",
144+
}, user_id="local", workspace_id=interaction_workspace_id, session_id="session-one",
143145
)
144146
page.reload(wait_until="domcontentloaded")
145147
page.wait_for_function("document.body.innerText.includes('Browser plan · v1')")

0 commit comments

Comments
 (0)