Compare commits

...

1 Commits

Author SHA1 Message Date
brent.edwards 02c2e9b596 Merge remote-tracking branch 'origin/master' into test/int-wf10-batch
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 22s
CI / lint (pull_request) Successful in 3m21s
CI / typecheck (pull_request) Successful in 4m38s
CI / quality (pull_request) Successful in 4m21s
CI / security (pull_request) Successful in 4m45s
CI / unit_tests (pull_request) Successful in 7m56s
CI / integration_tests (pull_request) Successful in 8m36s
CI / docker (pull_request) Successful in 1m12s
CI / e2e_tests (pull_request) Successful in 13m12s
CI / benchmark-regression (pull_request) Failing after 15m59s
CI / coverage (pull_request) Successful in 15m36s
CI / status-check (pull_request) Successful in 1s
2026-03-26 20:02:52 +00:00
5 changed files with 756 additions and 0 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
- Added Robot Framework integration test for Specification Workflow Example 10
(full-auto batch formatting and linting, full-auto profile). (#774)
- Added TDD bug-capture tests for bug #1076`use_action()` does not
propagate `automation_profile` to Plan. Three Behave BDD scenarios
(`@tdd_bug @tdd_bug_1076 @tdd_expected_fail`) verify the full precedence
+36
View File
@@ -0,0 +1,36 @@
@lsp @client
Feature: LSP Client
As a developer
I want a robust LSP client
So that I can communicate with language servers
Scenario: Initialize handshake success
Given an LSP client with a mock transport
When I initialize the client with workspace "/tmp/test-ws"
Then the client should send an "initialize" request
And the client should send an "initialized" notification
And the client should be initialized
Scenario: Shutdown sequence
Given an initialized LSP client
When I shutdown the client
Then the client should send a "shutdown" request
And the client should send an "exit" notification
And the client state should be uninitialized
Scenario: Text document notifications
Given an initialized LSP client
When I open a document "file:///test.py" with content "print('hello')"
Then the client should send a "textDocument/didOpen" notification
When I close the document "file:///test.py"
Then the client should send a "textDocument/didClose" notification
Scenario: Diagnostics handling
Given an initialized LSP client
When the server publishes diagnostics for "file:///test.py"
Then the client should have diagnostics for "file:///test.py"
Scenario: Completion request
Given an initialized LSP client
When I request completions for "file:///test.py" at line 1 character 5
Then the client should send a "textDocument/completion" request
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from behave import given, then, when
from cleveragents.lsp.client import LspClient
from cleveragents.lsp.transport import StdioTransport
class MockTransport(StdioTransport):
def __init__(self):
self.sent_messages = []
self.responses = []
self._is_alive = True
@property
def is_alive(self) -> bool:
return self._is_alive
def start(self) -> None:
pass
def stop(self, timeout=None) -> None:
pass
def send_message(self, body: dict) -> None:
self.sent_messages.append(body)
def read_message(self, timeout=None) -> dict | None:
if self.responses:
return self.responses.pop(0)
return None
def queue_response(self, response: dict):
self.responses.append(response)
@given("an LSP client with a mock transport")
def step_client_with_mock_transport(context):
context.transport = MockTransport()
# Mock __init__ of StdioTransport is bypassed by our subclass,
# but LspClient checks it.
context.client = LspClient(context.transport, server_name="test-server")
@when('I initialize the client with workspace "{workspace}"')
def step_initialize_client(context, workspace):
# Queue the response for the initialize request
# ID will be 1 because it's the first request
context.transport.queue_response(
{"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {"textDocumentSync": 1}}}
)
context.client.initialize(workspace)
@then('the client should send an "initialize" request')
def step_check_initialize_request(context):
msg = context.transport.sent_messages[0]
assert msg["method"] == "initialize"
@then('the client should send an "initialized" notification')
def step_check_initialized_notification(context):
msg = context.transport.sent_messages[1]
assert msg["method"] == "initialized"
@then("the client should be initialized")
def step_check_initialized(context):
assert context.client.is_initialized
@given("an initialized LSP client")
def step_initialized_client(context):
step_client_with_mock_transport(context)
step_initialize_client(context, "/tmp/ws")
context.transport.sent_messages.clear() # Clear init messages
# Reset request ID counter if needed, or track IDs.
# LspClient._request_id will be 1. Next request is 2.
@when("I shutdown the client")
def step_shutdown_client(context):
# Next ID is 2
context.transport.queue_response({"jsonrpc": "2.0", "id": 2, "result": None})
context.client.shutdown()
@then('the client should send a "shutdown" request')
def step_check_shutdown_request(context):
msg = next(m for m in context.transport.sent_messages if m["method"] == "shutdown")
assert msg
@then('the client should send an "exit" notification')
def step_check_exit_notification(context):
msg = next(m for m in context.transport.sent_messages if m["method"] == "exit")
assert msg
@then("the client state should be uninitialized")
def step_check_not_initialized(context):
assert not context.client.is_initialized
@when('I open a document "{uri}" with content "{content}"')
def step_open_document(context, uri, content):
context.client.did_open(uri, "python", 1, content)
@then('the client should send a "textDocument/didOpen" notification')
def step_check_did_open(context):
msg = context.transport.sent_messages[-1]
assert msg["method"] == "textDocument/didOpen"
@when('I close the document "{uri}"')
def step_close_document(context, uri):
context.client.did_close(uri)
@then('the client should send a "textDocument/didClose" notification')
def step_check_did_close(context):
msg = context.transport.sent_messages[-1]
assert msg["method"] == "textDocument/didClose"
@when('the server publishes diagnostics for "{uri}"')
def step_server_publishes_diagnostics(context, uri):
notification = {
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": [{"message": "error", "range": {}}]},
}
context.client._handle_notification(notification)
@then('the client should have diagnostics for "{uri}"')
def step_check_diagnostics(context, uri):
diags = context.client.get_diagnostics(uri)
assert len(diags) > 0
@when('I request completions for "{uri}" at line {line:d} character {char:d}')
def step_request_completions(context, uri, line, char):
# Next ID is 2 (after init)
context.transport.queue_response({"jsonrpc": "2.0", "id": 2, "result": []})
context.client.get_completions(uri, line, char)
@then('the client should send a "textDocument/completion" request')
def step_check_completion_request(context):
msg = context.transport.sent_messages[-1]
assert msg["method"] == "textDocument/completion"
+483
View File
@@ -0,0 +1,483 @@
"""Helper script for wf10_batch_auto.robot E2E tests.
Workflow Example 10: Full-Auto Batch Operations — Formatting and Linting.
Exercises the ``full-auto`` automation profile with batch plan execution
across multiple packages using mocked LLM providers.
Each subcommand is self-contained, sets up its own workspace, and prints
a sentinel on success. Exit code 0 = pass, 1 = failure.
Usage::
python robot/helper_wf10_batch_auto.py <command>
"""
from __future__ import annotations
import os
import shutil
import sys
from collections.abc import Callable
from pathlib import Path
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
_ROBOT = str(Path(__file__).resolve().parent)
if _ROBOT not in sys.path:
sys.path.insert(0, _ROBOT)
from helper_e2e_common import ( # noqa: E402
cleanup_workspace,
fail,
init_bare_git_repo,
run_cli,
setup_workspace,
write_yaml,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _init_workspace(prefix: str) -> str:
"""Create a workspace with Alembic migrations applied."""
return setup_workspace(prefix=prefix)
def _create_action(workspace: str, name: str) -> str:
"""Create a reusable full-auto action.
Returns the path to the temporary YAML file so callers can clean it up.
"""
yaml_path = write_yaml(
f"name: {name}\n"
"description: Batch format and lint package\n"
"automation_profile: full-auto\n"
"strategy_actor: openai/gpt-4\n"
"execution_actor: openai/gpt-4\n"
"definition_of_done: Code is formatted and lint-clean\n"
)
result = run_cli("action", "create", "--config", yaml_path, workspace=workspace)
if result.returncode != 0:
fail(
f"action create {name} rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
return yaml_path
def _register_resource(workspace: str) -> str:
"""Register a git-checkout resource.
Returns the path to the temporary git repo so callers can clean it up.
"""
repo_dir = init_bare_git_repo()
result = run_cli(
"resource",
"add",
"git-checkout",
"local/wf10-repo",
"--path",
repo_dir,
"--branch",
"main",
workspace=workspace,
)
if result.returncode != 0:
fail(
f"resource add rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
return repo_dir
def _create_project(
workspace: str,
name: str,
resource_name: str = "local/wf10-repo",
) -> None:
"""Create a project linked to a resource."""
result = run_cli(
"project",
"create",
name,
"--resource",
resource_name,
workspace=workspace,
)
if result.returncode != 0:
fail(
f"project create {name} rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def create_reusable_action() -> None:
"""Create a full-auto action that can be reused across packages."""
workspace = _init_workspace("wf10_action_")
yaml_path: str | None = None
try:
yaml_path = _create_action(workspace, "local/batch-format-lint")
print("action local/batch-format-lint created")
print("wf10-create-action-ok")
finally:
if yaml_path:
os.unlink(yaml_path)
cleanup_workspace(workspace)
def batch_plan_launch() -> None:
"""Launch plans for 3 packages using the same action."""
workspace = _init_workspace("wf10_batch_")
yaml_path: str | None = None
repo_dir: str | None = None
try:
yaml_path = _create_action(workspace, "local/batch-fmt")
repo_dir = _register_resource(workspace)
# Create 3 projects simulating 3 packages
projects = ["local/pkg-alpha", "local/pkg-beta", "local/pkg-gamma"]
for proj in projects:
_create_project(workspace, proj)
# Launch plan for each project using the same action
plan_ids: list[str] = []
for proj in projects:
result = run_cli(
"plan",
"use",
"local/batch-fmt",
proj,
"--automation-profile",
"full-auto",
"--format",
"plain",
workspace=workspace,
)
if result.returncode != 0:
fail(
f"plan use for {proj} rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
# Extract plan ID from plain-format output
for line in result.stdout.splitlines():
stripped = line.strip()
if stripped.startswith("plan_id:"):
pid = stripped.split(":", 1)[1].strip()
plan_ids.append(pid)
break
if not plan_ids:
fail(
"No plan IDs extracted from output after launching "
f"{len(projects)} plans"
)
print(f"Launched {len(plan_ids)} plans: {plan_ids}")
for proj in projects:
print(f"project: {proj}")
print("wf10-batch-plan-launch-ok")
finally:
if yaml_path:
os.unlink(yaml_path)
if repo_dir:
shutil.rmtree(repo_dir, ignore_errors=True)
cleanup_workspace(workspace)
def plan_list_monitoring() -> None:
"""Launch plans and verify ``plan lifecycle-list`` shows them."""
workspace = _init_workspace("wf10_list_")
yaml_path: str | None = None
repo_dir: str | None = None
try:
yaml_path = _create_action(workspace, "local/lint-batch")
repo_dir = _register_resource(workspace)
projects = ["local/svc-one", "local/svc-two", "local/svc-three"]
for proj in projects:
_create_project(workspace, proj)
# Launch plans
for proj in projects:
result = run_cli(
"plan",
"use",
"local/lint-batch",
proj,
"--automation-profile",
"full-auto",
workspace=workspace,
)
if result.returncode != 0:
fail(f"plan use {proj} rc={result.returncode}\nstderr: {result.stderr}")
# List all plans
result = run_cli(
"plan",
"lifecycle-list",
"--format",
"plain",
workspace=workspace,
)
if result.returncode != 0:
fail(
f"plan lifecycle-list rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
if "Traceback" in result.stderr or "INTERNAL" in result.stderr:
fail(f"plan lifecycle-list errors:\nstderr: {result.stderr}")
# Verify that we see plans in the output
stdout_lower = result.stdout.lower()
if "total: 0" in stdout_lower or not result.stdout.strip():
fail(
"plan lifecycle-list shows no plans after launching 3\n"
f"stdout: {result.stdout}"
)
print(f"plan-count: {len(projects)}")
print("Plan lifecycle-list monitoring: OK")
print("wf10-plan-list-monitoring-ok")
finally:
if yaml_path:
os.unlink(yaml_path)
if repo_dir:
shutil.rmtree(repo_dir, ignore_errors=True)
cleanup_workspace(workspace)
def plan_list_filter_by_phase() -> None:
"""Verify ``plan lifecycle-list --phase`` filtering works."""
workspace = _init_workspace("wf10_filter_")
yaml_path: str | None = None
repo_dir: str | None = None
try:
yaml_path = _create_action(workspace, "local/fmt-filter")
repo_dir = _register_resource(workspace)
_create_project(workspace, "local/filter-proj")
result = run_cli(
"plan",
"use",
"local/fmt-filter",
"local/filter-proj",
"--automation-profile",
"full-auto",
workspace=workspace,
)
if result.returncode != 0:
fail(f"plan use rc={result.returncode}\nstderr: {result.stderr}")
# Filter by strategize phase (plans start in strategize/queued)
result = run_cli(
"plan",
"lifecycle-list",
"--phase",
"strategize",
"--format",
"plain",
workspace=workspace,
)
if result.returncode != 0:
fail(
"plan lifecycle-list --phase strategize "
f"rc={result.returncode}\nstderr: {result.stderr}"
)
if "Traceback" in result.stderr or "INTERNAL" in result.stderr:
fail(f"lifecycle-list --phase errors:\nstderr: {result.stderr}")
print("phase: strategize")
print("Plan lifecycle-list phase filter: OK")
print("wf10-plan-filter-phase-ok")
finally:
if yaml_path:
os.unlink(yaml_path)
if repo_dir:
shutil.rmtree(repo_dir, ignore_errors=True)
cleanup_workspace(workspace)
def batch_execute_graceful() -> None:
"""Execute plans in batch; verify graceful handling with mock AI."""
workspace = _init_workspace("wf10_exec_")
yaml_path: str | None = None
repo_dir: str | None = None
try:
yaml_path = _create_action(workspace, "local/batch-exec")
repo_dir = _register_resource(workspace)
projects = ["local/exec-a", "local/exec-b"]
for proj in projects:
_create_project(workspace, proj)
# Launch plans for each project
plan_ids: list[str] = []
for proj in projects:
result = run_cli(
"plan",
"use",
"local/batch-exec",
proj,
"--automation-profile",
"full-auto",
"--format",
"plain",
workspace=workspace,
)
if result.returncode != 0:
fail(f"plan use {proj} rc={result.returncode}\nstderr: {result.stderr}")
# Extract plan ID from plain-format output
for line in result.stdout.splitlines():
stripped = line.strip()
if stripped.startswith("plan_id:"):
pid = stripped.split(":", 1)[1].strip()
plan_ids.append(pid)
break
# Execute each plan — with mock AI the plans are in
# strategize/queued so execute returns gracefully
for pid in plan_ids:
result = run_cli(
"plan",
"execute",
pid,
workspace=workspace,
)
# Non-zero is acceptable (plan not ready) but no crashes
if "Traceback" in result.stderr:
fail(f"plan execute {pid} produced Traceback:\nstderr: {result.stderr}")
print(f"plan execute {pid}: rc={result.returncode}")
# Verify lifecycle-list still works after execute attempts
result = run_cli(
"plan",
"lifecycle-list",
"--format",
"plain",
workspace=workspace,
)
if result.returncode != 0:
fail(f"lifecycle-list rc={result.returncode}\nstderr: {result.stderr}")
if "Traceback" in result.stderr or "INTERNAL" in result.stderr:
fail(f"batch execute errors:\nstderr: {result.stderr}")
print("Batch execute graceful: OK")
print("wf10-batch-execute-ok")
finally:
if yaml_path:
os.unlink(yaml_path)
if repo_dir:
shutil.rmtree(repo_dir, ignore_errors=True)
cleanup_workspace(workspace)
def error_handling() -> None:
"""Demonstrate error handling when a plan references invalid action."""
workspace = _init_workspace("wf10_error_")
repo_dir: str | None = None
try:
repo_dir = _register_resource(workspace)
_create_project(workspace, "local/error-proj")
# Try to use a non-existent action
result = run_cli(
"plan",
"use",
"local/nonexistent-action",
"local/error-proj",
"--automation-profile",
"full-auto",
workspace=workspace,
)
# Should fail gracefully (non-zero exit code, no crash)
if result.returncode == 0:
fail("plan use with nonexistent action should fail")
if "Traceback" in result.stderr:
fail(
f"plan use with bad action produced Traceback:\nstderr: {result.stderr}"
)
print(f"error-rc: {result.returncode}")
print("Error handling for invalid action: graceful failure")
print("wf10-error-handling-ok")
finally:
if repo_dir:
shutil.rmtree(repo_dir, ignore_errors=True)
cleanup_workspace(workspace)
def full_auto_profile_verification() -> None:
"""Verify full-auto profile is accepted in plan use."""
workspace = _init_workspace("wf10_profile_")
yaml_path: str | None = None
repo_dir: str | None = None
try:
yaml_path = _create_action(workspace, "local/auto-lint")
repo_dir = _register_resource(workspace)
_create_project(workspace, "local/auto-proj")
result = run_cli(
"plan",
"use",
"local/auto-lint",
"local/auto-proj",
"--automation-profile",
"full-auto",
workspace=workspace,
)
if result.returncode != 0:
fail(
"plan use --automation-profile full-auto "
f"rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
if "Traceback" in result.stderr or "INTERNAL" in result.stderr:
fail(f"full-auto profile errors:\nstderr: {result.stderr}")
print("automation-profile: full-auto")
print("Full-auto profile accepted: OK")
print("wf10-full-auto-profile-ok")
finally:
if yaml_path:
os.unlink(yaml_path)
if repo_dir:
shutil.rmtree(repo_dir, ignore_errors=True)
cleanup_workspace(workspace)
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"create-action": create_reusable_action,
"batch-plan-launch": batch_plan_launch,
"plan-list-monitoring": plan_list_monitoring,
"plan-filter-phase": plan_list_filter_by_phase,
"batch-execute": batch_execute_graceful,
"error-handling": error_handling,
"full-auto-profile": full_auto_profile_verification,
}
def main() -> int:
"""Dispatch to the requested subcommand."""
if len(sys.argv) < 2:
cmds = "|".join(_COMMANDS)
print(f"Usage: helper_wf10_batch_auto.py <{cmds}>")
return 1
command = sys.argv[1]
handler = _COMMANDS.get(command)
if handler is None:
print(f"Unknown command: {command}")
return 1
handler()
return 0
if __name__ == "__main__":
sys.exit(main())
+82
View File
@@ -0,0 +1,82 @@
*** Settings ***
Documentation WF10: Full-auto batch formatting and linting E2E tests.
... Exercises the full-auto profile with batch plan execution
... across multiple packages using mocked LLM providers.
Resource ${CURDIR}/common.resource
Force Tags wf10 batch full-auto integration
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_wf10_batch_auto.py
*** Test Cases ***
WF10 Create Reusable Full Auto Action
[Documentation] Create a full-auto action for batch formatting.
[Tags] action create
${result}= Run Process ${PYTHON} ${HELPER} create-action cwd=${SUITE_HOME} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wf10-create-action-ok
Should Contain ${result.stdout} batch-format-lint
WF10 Batch Plan Launch Three Packages
[Documentation] Launch plans for 3 packages using the same action.
[Tags] plan batch launch
${result}= Run Process ${PYTHON} ${HELPER} batch-plan-launch cwd=${SUITE_HOME} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wf10-batch-plan-launch-ok
Should Contain ${result.stdout} project: local/pkg-alpha
WF10 Plan List Monitoring
[Documentation] Verify plan lifecycle-list shows launched plans.
[Tags] plan lifecycle list
${result}= Run Process ${PYTHON} ${HELPER} plan-list-monitoring cwd=${SUITE_HOME} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wf10-plan-list-monitoring-ok
Should Contain ${result.stdout} plan-count: 3
WF10 Plan List Filter By Phase
[Documentation] Verify plan lifecycle-list --phase filtering works.
[Tags] plan lifecycle filter
${result}= Run Process ${PYTHON} ${HELPER} plan-filter-phase cwd=${SUITE_HOME} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wf10-plan-filter-phase-ok
Should Contain ${result.stdout} phase: strategize
WF10 Batch Execute Graceful
[Documentation] Execute plans in batch with graceful mock-AI handling.
[Tags] plan execute batch
${result}= Run Process ${PYTHON} ${HELPER} batch-execute cwd=${SUITE_HOME} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wf10-batch-execute-ok
Should Contain ${result.stdout} plan execute
WF10 Error Handling Invalid Action
[Documentation] Demonstrate graceful error when referencing invalid action.
[Tags] plan error negative
${result}= Run Process ${PYTHON} ${HELPER} error-handling cwd=${SUITE_HOME} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wf10-error-handling-ok
Should Contain ${result.stdout} error-rc:
WF10 Full Auto Profile Verification
[Documentation] Verify full-auto automation profile is accepted.
[Tags] plan profile full-auto
${result}= Run Process ${PYTHON} ${HELPER} full-auto-profile cwd=${SUITE_HOME} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} wf10-full-auto-profile-ok
Should Contain ${result.stdout} automation-profile: full-auto