Compare commits
5 Commits
2ee2887eb9
...
dd86d8cd2d
| Author | SHA1 | Date | |
|---|---|---|---|
| dd86d8cd2d | |||
| e90db58194 | |||
| d9fe2747a1 | |||
| 4895a2a91b | |||
| 66b6df03a6 |
@@ -2,7 +2,7 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
composer git openssh-client php-cli php-curl php-mbstring php-mysql php-xml php-zip unzip \
|
composer git nodejs npm openssh-client php-cli php-curl php-mbstring php-mysql php-xml php-zip unzip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -43,10 +43,15 @@ Die nachgelagerten Testprozesse erhalten separat konfigurierte `TEST_DB_*`-Zugan
|
|||||||
Dieser Benutzer darf ausschliesslich die isolierten Testschemas bearbeiten. Fuer Laravel
|
Dieser Benutzer darf ausschliesslich die isolierten Testschemas bearbeiten. Fuer Laravel
|
||||||
werden daraus `DB_*`, `ERP_DB_*` und `COCKPIT_CONFIG_DATABASE` gesetzt; Aider selbst sieht
|
werden daraus `DB_*`, `ERP_DB_*` und `COCKPIT_CONFIG_DATABASE` gesetzt; Aider selbst sieht
|
||||||
diese Werte nie. Produktive ERP- oder Kundenzugangsdaten duerfen hier nicht verwendet werden.
|
diese Werte nie. Produktive ERP- oder Kundenzugangsdaten duerfen hier nicht verwendet werden.
|
||||||
|
Vor jedem Testlauf erstellt `npm run build` die Vite-Assets und initialisiert
|
||||||
|
`php artisan migrate:fresh --force` das isolierte Laravel-Schema neu. Deshalb muessen alle
|
||||||
|
`TEST_DB_*`-Schemas dediziert und vollstaendig entbehrlich sein.
|
||||||
|
|
||||||
Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket
|
Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket
|
||||||
des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90
|
des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90
|
||||||
Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
|
Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
|
||||||
|
Schlaegt die isolierte Validierung fehl, erhaelt Aider standardmaessig genau einen
|
||||||
|
Korrekturversuch mit redigierter Testausgabe. `MAX_AGENT_ATTEMPTS` ist auf 1 bis 3 begrenzt.
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ services:
|
|||||||
AGENT_MODEL: ${AGENT_MODEL:-mistral/devstral-latest}
|
AGENT_MODEL: ${AGENT_MODEL:-mistral/devstral-latest}
|
||||||
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
|
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
|
||||||
POLL_SECONDS: ${POLL_SECONDS:-60}
|
POLL_SECONDS: ${POLL_SECONDS:-60}
|
||||||
|
MAX_AGENT_ATTEMPTS: ${MAX_AGENT_ATTEMPTS:-2}
|
||||||
STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90}
|
STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90}
|
||||||
TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;php artisan test}
|
TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;npm ci;;npm run build;;php artisan migrate:fresh --force;;php artisan test}
|
||||||
TEST_DB_HOST: ${TEST_DB_HOST:-host.docker.internal}
|
TEST_DB_HOST: ${TEST_DB_HOST:-host.docker.internal}
|
||||||
TEST_DB_PORT: ${TEST_DB_PORT:-33060}
|
TEST_DB_PORT: ${TEST_DB_PORT:-33060}
|
||||||
TEST_DB_NAME: ${TEST_DB_NAME:-laravel_ai_chat_agent_test}
|
TEST_DB_NAME: ${TEST_DB_NAME:-laravel_ai_chat_agent_test}
|
||||||
|
|||||||
@@ -86,5 +86,5 @@ class AiderAgent:
|
|||||||
"--edit-format",
|
"--edit-format",
|
||||||
"diff",
|
"diff",
|
||||||
"--timeout",
|
"--timeout",
|
||||||
"60",
|
"180",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class Settings:
|
|||||||
poll_seconds: int
|
poll_seconds: int
|
||||||
max_changed_files: int
|
max_changed_files: int
|
||||||
max_changed_lines: int
|
max_changed_lines: int
|
||||||
|
max_agent_attempts: int
|
||||||
stale_claim_minutes: int
|
stale_claim_minutes: int
|
||||||
dry_run: bool
|
dry_run: bool
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ class Settings:
|
|||||||
test_commands=_commands(
|
test_commands=_commands(
|
||||||
os.getenv(
|
os.getenv(
|
||||||
"TEST_COMMANDS",
|
"TEST_COMMANDS",
|
||||||
"composer install --no-interaction --prefer-dist;;php artisan test",
|
"composer install --no-interaction --prefer-dist;;npm ci;;npm run build;;php artisan migrate:fresh --force;;php artisan test",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
test_database_host=os.getenv("TEST_DB_HOST", "host.docker.internal"),
|
test_database_host=os.getenv("TEST_DB_HOST", "host.docker.internal"),
|
||||||
@@ -88,6 +89,7 @@ class Settings:
|
|||||||
poll_seconds=int(os.getenv("POLL_SECONDS", "60")),
|
poll_seconds=int(os.getenv("POLL_SECONDS", "60")),
|
||||||
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
|
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
|
||||||
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
|
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
|
||||||
|
max_agent_attempts=int(os.getenv("MAX_AGENT_ATTEMPTS", "2")),
|
||||||
stale_claim_minutes=int(os.getenv("STALE_CLAIM_MINUTES", "90")),
|
stale_claim_minutes=int(os.getenv("STALE_CLAIM_MINUTES", "90")),
|
||||||
dry_run=mode == "dry-run",
|
dry_run=mode == "dry-run",
|
||||||
)
|
)
|
||||||
@@ -103,3 +105,5 @@ class Settings:
|
|||||||
raise ValueError("FAILURE_DB_PASSWORD is required")
|
raise ValueError("FAILURE_DB_PASSWORD is required")
|
||||||
if not self.test_database_password:
|
if not self.test_database_password:
|
||||||
raise ValueError("TEST_DB_PASSWORD is required")
|
raise ValueError("TEST_DB_PASSWORD is required")
|
||||||
|
if not 1 <= self.max_agent_attempts <= 3:
|
||||||
|
raise ValueError("MAX_AGENT_ATTEMPTS must be between 1 and 3")
|
||||||
|
|||||||
@@ -33,6 +33,32 @@ def build_task(report: FailureReport) -> str:
|
|||||||
response = redact(report.response)
|
response = redact(report.response)
|
||||||
error = redact(report.error_message)
|
error = redact(report.error_message)
|
||||||
sql = redact(report.sql_query)
|
sql = redact(report.sql_query)
|
||||||
|
tenant_scope = ''
|
||||||
|
if report.context.get('database_type') == 'customer':
|
||||||
|
tenant_scope = """
|
||||||
|
Tenant routing fact:
|
||||||
|
The customer has already selected one tenant database. SQL inside that database must not
|
||||||
|
invent an additional customer discriminator column. Use only columns present in tracked
|
||||||
|
schema/semantics, and fix SQL-generation guidance rather than rewriting one unknown column
|
||||||
|
to another guessed column. TenantDatabaseService records the explicit source type in
|
||||||
|
database.connections.<connection>.tenant_type; use that metadata instead of database-name
|
||||||
|
patterns or heuristics.
|
||||||
|
"""
|
||||||
|
sql_semantics = ''
|
||||||
|
if report.category in {'incorrect_answer', 'sql_execution'}:
|
||||||
|
sql_semantics = """
|
||||||
|
SQL repair guidance:
|
||||||
|
When the SQL is syntactically valid but uses the wrong business calculation, prefer adding or
|
||||||
|
correcting tracked domain knowledge in config/*_semantics.php so the schema prompt teaches the
|
||||||
|
model the right joins, filters, and formula. Do not hardcode one user's question or article ID
|
||||||
|
in SQLGeneratorService, and do not add post-generation replacement of guessed SQL.
|
||||||
|
|
||||||
|
For regression coverage of prompt guidance, expose the relevant protected schema/business-
|
||||||
|
context builder through a test subclass and assert that its real output contains the required
|
||||||
|
formula and rules. Do not mock AIService to return the known-bad SQL and then expect prompt
|
||||||
|
guidance to rewrite that mocked response. Keep database fixture tests only when production code
|
||||||
|
itself deterministically builds and executes the SQL under test.
|
||||||
|
"""
|
||||||
|
|
||||||
return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository.
|
return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository.
|
||||||
|
|
||||||
@@ -68,6 +94,27 @@ Observed error:
|
|||||||
|
|
||||||
Generated SQL, if relevant:
|
Generated SQL, if relevant:
|
||||||
{sql or '[not available]'}
|
{sql or '[not available]'}
|
||||||
|
{tenant_scope}
|
||||||
|
{sql_semantics}
|
||||||
|
|
||||||
Run focused tests while working. Leave the repository with the fix and regression test applied.
|
Run focused tests while working. Leave the repository with the fix and regression test applied.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_retry_task(report: FailureReport, exception: Exception) -> str:
|
||||||
|
failure = redact(str(exception))[-6_000:]
|
||||||
|
|
||||||
|
return build_task(report) + f"""
|
||||||
|
|
||||||
|
The previous implementation failed isolated validation.
|
||||||
|
|
||||||
|
Correct the existing changes using this test output:
|
||||||
|
|
||||||
|
{failure}
|
||||||
|
|
||||||
|
Keep the regression test meaningful. Do not remove or weaken tests, safety checks,
|
||||||
|
authentication, authorization, SQL validation, or tenant isolation. If a new test calls an
|
||||||
|
unrelated specialized code path that correctly returns no result, repair the test to exercise
|
||||||
|
the owning production behavior; never broaden unrelated production routing merely to make the
|
||||||
|
test pass. Do not commit or push.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from .git import GitWorkspace
|
|||||||
from .gitea import GiteaClient
|
from .gitea import GiteaClient
|
||||||
from .models import FailureReport
|
from .models import FailureReport
|
||||||
from .policy import evaluate_changes
|
from .policy import evaluate_changes
|
||||||
from .prompt import build_task
|
from .prompt import build_retry_task, build_task
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -43,15 +43,42 @@ class AutomationWorker:
|
|||||||
try:
|
try:
|
||||||
workspace.prepare()
|
workspace.prepare()
|
||||||
task = build_task(report)
|
task = build_task(report)
|
||||||
metadata["agent_output"] = self.agent.run(workspace.path, task)
|
metadata["agent_output"] = []
|
||||||
|
for attempt in range(1, self.settings.max_agent_attempts + 1):
|
||||||
|
metadata["agent_output"].append(self.agent.run(workspace.path, task))
|
||||||
|
summary = workspace.summary()
|
||||||
|
metadata["changes"] = asdict(summary)
|
||||||
|
policy = evaluate_changes(summary, self.settings, workspace.path)
|
||||||
|
if not policy.allowed:
|
||||||
|
if not summary.files and attempt < self.settings.max_agent_attempts:
|
||||||
|
LOG.warning(
|
||||||
|
"Agent produced no changes for report %s on attempt %s; requesting correction",
|
||||||
|
report.id,
|
||||||
|
attempt,
|
||||||
|
)
|
||||||
|
task = build_retry_task(
|
||||||
|
report,
|
||||||
|
RuntimeError(
|
||||||
|
"No file changes were applied. Make the smallest concrete code and regression-test edits now."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
raise RuntimeError(
|
||||||
|
"Policy rejected changes: " + "; ".join(policy.errors)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
metadata["tests"] = self._run_tests(workspace)
|
||||||
|
break
|
||||||
|
except RuntimeError as exception:
|
||||||
|
if attempt >= self.settings.max_agent_attempts:
|
||||||
|
raise
|
||||||
|
LOG.warning(
|
||||||
|
"Tests failed for report %s on attempt %s; requesting correction",
|
||||||
|
report.id,
|
||||||
|
attempt,
|
||||||
|
)
|
||||||
|
task = build_retry_task(report, exception)
|
||||||
|
|
||||||
summary = workspace.summary()
|
|
||||||
metadata["changes"] = asdict(summary)
|
|
||||||
policy = evaluate_changes(summary, self.settings, workspace.path)
|
|
||||||
if not policy.allowed:
|
|
||||||
raise RuntimeError("Policy rejected changes: " + "; ".join(policy.errors))
|
|
||||||
|
|
||||||
metadata["tests"] = self._run_tests(workspace)
|
|
||||||
summary = workspace.summary()
|
summary = workspace.summary()
|
||||||
metadata["changes"] = asdict(summary)
|
metadata["changes"] = asdict(summary)
|
||||||
policy = evaluate_changes(summary, self.settings, workspace.path)
|
policy = evaluate_changes(summary, self.settings, workspace.path)
|
||||||
@@ -101,6 +128,9 @@ class AutomationWorker:
|
|||||||
results = []
|
results = []
|
||||||
test_environment = {
|
test_environment = {
|
||||||
"AGENT_TEST_DB_ENABLED": "true",
|
"AGENT_TEST_DB_ENABLED": "true",
|
||||||
|
"APP_ENV": "testing",
|
||||||
|
"APP_KEY": "base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
||||||
|
"AI_MISTRAL_API_KEY": "automation-test-key",
|
||||||
"TEST_DB_HOST": self.settings.test_database_host,
|
"TEST_DB_HOST": self.settings.test_database_host,
|
||||||
"TEST_DB_PORT": str(self.settings.test_database_port),
|
"TEST_DB_PORT": str(self.settings.test_database_port),
|
||||||
"TEST_DB_NAME": self.settings.test_database_name,
|
"TEST_DB_NAME": self.settings.test_database_name,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def test_aider_command_cannot_mutate_git_configuration_or_send_analytics() -> No
|
|||||||
assert "--no-analytics" in command
|
assert "--no-analytics" in command
|
||||||
assert "--no-auto-commits" in command
|
assert "--no-auto-commits" in command
|
||||||
assert command.count("/dev/null") == 2
|
assert command.count("/dev/null") == 2
|
||||||
assert command[-4:] == ["--edit-format", "diff", "--timeout", "60"]
|
assert command[-4:] == ["--edit-format", "diff", "--timeout", "180"]
|
||||||
|
|
||||||
|
|
||||||
def test_aider_environment_does_not_include_database_credentials(tmp_path, monkeypatch) -> None:
|
def test_aider_environment_does_not_include_database_credentials(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def settings(**overrides) -> Settings:
|
|||||||
"poll_seconds": 60,
|
"poll_seconds": 60,
|
||||||
"max_changed_files": 12,
|
"max_changed_files": 12,
|
||||||
"max_changed_lines": 800,
|
"max_changed_lines": 800,
|
||||||
|
"max_agent_attempts": 2,
|
||||||
"stale_claim_minutes": 90,
|
"stale_claim_minutes": 90,
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
}
|
}
|
||||||
@@ -52,3 +53,21 @@ def test_pr_requires_gitea_token() -> None:
|
|||||||
config = settings(mode="pr", dry_run=False)
|
config = settings(mode="pr", dry_run=False)
|
||||||
with pytest.raises(ValueError, match="GITEA_TOKEN"):
|
with pytest.raises(ValueError, match="GITEA_TOKEN"):
|
||||||
config.validate()
|
config.validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_commands_initialize_disposable_laravel_schema(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("FAILURE_DB_PASSWORD", "failure-secret")
|
||||||
|
monkeypatch.setenv("TEST_DB_PASSWORD", "test-secret")
|
||||||
|
monkeypatch.delenv("TEST_COMMANDS", raising=False)
|
||||||
|
|
||||||
|
config = Settings.from_env()
|
||||||
|
|
||||||
|
assert "npm ci" in config.test_commands
|
||||||
|
assert "npm run build" in config.test_commands
|
||||||
|
assert "php artisan migrate:fresh --force" in config.test_commands
|
||||||
|
assert config.test_commands[-1] == "php artisan test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_attempts_are_bounded() -> None:
|
||||||
|
with pytest.raises(ValueError, match="MAX_AGENT_ATTEMPTS"):
|
||||||
|
settings(max_agent_attempts=4).validate()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from lai_automation.models import FailureReport
|
from lai_automation.models import FailureReport
|
||||||
from lai_automation.prompt import build_task, redact
|
from lai_automation.prompt import build_retry_task, build_task, redact
|
||||||
|
|
||||||
|
|
||||||
def test_redact_removes_common_secrets_and_personal_markers() -> None:
|
def test_redact_removes_common_secrets_and_personal_markers() -> None:
|
||||||
@@ -51,3 +51,77 @@ def test_redact_removes_extended_credentials() -> None:
|
|||||||
assert "eyJhbGci" not in result
|
assert "eyJhbGci" not in result
|
||||||
assert "database-password" not in result
|
assert "database-password" not in result
|
||||||
assert "private-material" not in result
|
assert "private-material" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_task_contains_failure_without_secrets() -> None:
|
||||||
|
report = FailureReport(
|
||||||
|
id=8,
|
||||||
|
chat_message_id=1,
|
||||||
|
source="user_feedback",
|
||||||
|
category="incorrect_answer",
|
||||||
|
user_feedback="Wrong result",
|
||||||
|
feedback_type="wrong_result",
|
||||||
|
expected_behavior="Use a weighted average",
|
||||||
|
prompt="Average sale price",
|
||||||
|
response="Zero",
|
||||||
|
sql_query="SELECT AVG(list_price)",
|
||||||
|
error_message=None,
|
||||||
|
context={"database_type": "erp"},
|
||||||
|
)
|
||||||
|
task = build_retry_task(report, RuntimeError("Missing import; token=secret-value"))
|
||||||
|
|
||||||
|
assert "Missing import" in task
|
||||||
|
assert "secret-value" not in task
|
||||||
|
assert "Do not remove or weaken tests" in task
|
||||||
|
assert "unrelated production routing" in task
|
||||||
|
assert "Use a weighted average" in task
|
||||||
|
assert "SELECT AVG(list_price)" in task
|
||||||
|
|
||||||
|
|
||||||
|
def test_incorrect_answer_task_guides_semantics_level_repair() -> None:
|
||||||
|
report = FailureReport(
|
||||||
|
id=42,
|
||||||
|
chat_message_id=1,
|
||||||
|
source="user_feedback",
|
||||||
|
category="incorrect_answer",
|
||||||
|
user_feedback="Wrong result",
|
||||||
|
feedback_type="wrong_result",
|
||||||
|
expected_behavior="Sum net values and divide by quantity",
|
||||||
|
prompt="Average sale price for article 9031",
|
||||||
|
response="0.00",
|
||||||
|
sql_query="SELECT AVG(Vk_preis) FROM FK_Artikel",
|
||||||
|
error_message=None,
|
||||||
|
context={"database_type": "erp"},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = build_task(report)
|
||||||
|
|
||||||
|
assert "config/*_semantics.php" in task
|
||||||
|
assert "Do not hardcode" in task
|
||||||
|
assert "protected schema/business-" in task
|
||||||
|
assert "context builder" in task
|
||||||
|
assert "known-bad SQL" in task
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_database_task_explains_tenant_scope() -> None:
|
||||||
|
report = FailureReport(
|
||||||
|
id=13,
|
||||||
|
chat_message_id=1,
|
||||||
|
source="automatic",
|
||||||
|
category="sql_execution",
|
||||||
|
user_feedback=None,
|
||||||
|
feedback_type=None,
|
||||||
|
expected_behavior=None,
|
||||||
|
prompt="What did this customer buy most?",
|
||||||
|
response=None,
|
||||||
|
sql_query="SELECT * FROM invoice_row WHERE InvoiceCreator = 'tenant'",
|
||||||
|
error_message="Unknown column InvoiceCreator",
|
||||||
|
context={"database_type": "customer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = build_task(report)
|
||||||
|
|
||||||
|
assert "already selected one tenant database" in task
|
||||||
|
assert "must not" in task
|
||||||
|
assert "guessed column" in task
|
||||||
|
assert "tenant_type" in task
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from unittest.mock import Mock
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from test_config import settings
|
from test_config import settings
|
||||||
|
|
||||||
|
from lai_automation.git import ChangeSummary
|
||||||
from lai_automation.models import FailureReport
|
from lai_automation.models import FailureReport
|
||||||
from lai_automation.worker import AutomationWorker
|
from lai_automation.worker import AutomationWorker
|
||||||
|
|
||||||
@@ -49,8 +51,91 @@ def test_database_credentials_are_only_passed_to_test_process() -> None:
|
|||||||
assert results[0]["returncode"] == 0
|
assert results[0]["returncode"] == 0
|
||||||
environment = workspace.run.call_args.kwargs["extra_environment"]
|
environment = workspace.run.call_args.kwargs["extra_environment"]
|
||||||
assert environment["AGENT_TEST_DB_ENABLED"] == "true"
|
assert environment["AGENT_TEST_DB_ENABLED"] == "true"
|
||||||
|
assert environment["APP_ENV"] == "testing"
|
||||||
|
assert environment["AI_MISTRAL_API_KEY"] == "automation-test-key"
|
||||||
assert environment["TEST_DB_USER"] == "test_runner"
|
assert environment["TEST_DB_USER"] == "test_runner"
|
||||||
assert environment["TEST_DB_PASSWORD"] == "test-secret"
|
assert environment["TEST_DB_PASSWORD"] == "test-secret"
|
||||||
assert environment["DB_DATABASE"] == "lai_agent_test"
|
assert environment["DB_DATABASE"] == "lai_agent_test"
|
||||||
assert environment["ERP_DATABASE"] == "customer_agent_test"
|
assert environment["ERP_DATABASE"] == "customer_agent_test"
|
||||||
assert environment["COCKPIT_CONFIG_DATABASE"] == "cockpit_agent_test"
|
assert environment["COCKPIT_CONFIG_DATABASE"] == "cockpit_agent_test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_validation_gets_one_correction_before_pr() -> None:
|
||||||
|
report = FailureReport(
|
||||||
|
id=13,
|
||||||
|
chat_message_id=1,
|
||||||
|
source="automatic",
|
||||||
|
category="sql_execution",
|
||||||
|
user_feedback=None,
|
||||||
|
feedback_type=None,
|
||||||
|
expected_behavior=None,
|
||||||
|
prompt="List products",
|
||||||
|
response=None,
|
||||||
|
sql_query="SELECT missing_column FROM invoice_row",
|
||||||
|
error_message="Unknown column",
|
||||||
|
context={},
|
||||||
|
)
|
||||||
|
config = settings(mode="pr", dry_run=False, gitea_token="token")
|
||||||
|
worker = AutomationWorker(config)
|
||||||
|
worker.reports = Mock()
|
||||||
|
worker.reports.claim_next.return_value = report
|
||||||
|
worker.agent = Mock()
|
||||||
|
worker.agent.run.side_effect = ["first change", "corrected change"]
|
||||||
|
worker.gitea = Mock()
|
||||||
|
worker._run_tests = Mock(side_effect=[RuntimeError("Missing import"), []])
|
||||||
|
workspace = Mock()
|
||||||
|
workspace.path = Path("/tmp/nonexistent-workspace")
|
||||||
|
workspace.branch = "ai/failure-13"
|
||||||
|
workspace.summary.return_value = ChangeSummary(("tests/Feature/FixTest.php",), 5, 0)
|
||||||
|
workspace.commit.return_value = "commit-id"
|
||||||
|
|
||||||
|
with patch("lai_automation.worker.GitWorkspace", return_value=workspace):
|
||||||
|
assert worker.run_once()
|
||||||
|
|
||||||
|
assert worker.agent.run.call_count == 2
|
||||||
|
assert "Missing import" in worker.agent.run.call_args_list[1].args[1]
|
||||||
|
assert worker._run_tests.call_count == 2
|
||||||
|
workspace.push_branch.assert_called_once()
|
||||||
|
worker.reports.complete.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_change_response_gets_one_correction() -> None:
|
||||||
|
report = FailureReport(
|
||||||
|
id=14,
|
||||||
|
chat_message_id=1,
|
||||||
|
source="automatic",
|
||||||
|
category="sql_execution",
|
||||||
|
user_feedback=None,
|
||||||
|
feedback_type=None,
|
||||||
|
expected_behavior=None,
|
||||||
|
prompt="List products",
|
||||||
|
response=None,
|
||||||
|
sql_query=None,
|
||||||
|
error_message="Unknown column",
|
||||||
|
context={},
|
||||||
|
)
|
||||||
|
config = settings(mode="pr", dry_run=False, gitea_token="token")
|
||||||
|
worker = AutomationWorker(config)
|
||||||
|
worker.reports = Mock()
|
||||||
|
worker.reports.claim_next.return_value = report
|
||||||
|
worker.agent = Mock()
|
||||||
|
worker.agent.run.side_effect = ["no edit", "applied edit"]
|
||||||
|
worker.gitea = Mock()
|
||||||
|
worker._run_tests = Mock(return_value=[])
|
||||||
|
workspace = Mock()
|
||||||
|
workspace.path = Path("/tmp/nonexistent-workspace")
|
||||||
|
workspace.branch = "ai/failure-14"
|
||||||
|
workspace.summary.side_effect = [
|
||||||
|
ChangeSummary((), 0, 0),
|
||||||
|
ChangeSummary(("tests/Feature/FixTest.php",), 5, 0),
|
||||||
|
ChangeSummary(("tests/Feature/FixTest.php",), 5, 0),
|
||||||
|
]
|
||||||
|
workspace.commit.return_value = "commit-id"
|
||||||
|
|
||||||
|
with patch("lai_automation.worker.GitWorkspace", return_value=workspace):
|
||||||
|
assert worker.run_once()
|
||||||
|
|
||||||
|
assert worker.agent.run.call_count == 2
|
||||||
|
assert "No file changes" in worker.agent.run.call_args_list[1].args[1]
|
||||||
|
worker._run_tests.assert_called_once()
|
||||||
|
workspace.push_branch.assert_called_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user