diff --git a/skills/scripts/e2e/ensure-local-agent-pipeline.mjs b/skills/scripts/e2e/ensure-local-agent-pipeline.mjs index d7ee57e2d..be5b5ec9f 100755 --- a/skills/scripts/e2e/ensure-local-agent-pipeline.mjs +++ b/skills/scripts/e2e/ensure-local-agent-pipeline.mjs @@ -441,10 +441,10 @@ async function ensureLocalAgentPipeline({ pipelineName, runnerId, }) { - const fakeProviderBaseUrl = - env.LANGBOT_E2E_FAKE_PROVIDER_BASE_URL || - env.LANGBOT_FAKE_PROVIDER_BASE_URL || - ""; + // Fake-provider use must be explicit for this pipeline. The generic fake + // provider variables persist after load tests and must not silently replace + // a real LocalAgent model in later QA runs. + const fakeProviderBaseUrl = env.LANGBOT_E2E_FAKE_PROVIDER_BASE_URL || ""; let fakeModel = null; if (fakeProviderBaseUrl) { fakeModel = await ensureFakeProviderModel({ diff --git a/skills/scripts/e2e/reset-complex-agent-task.mjs b/skills/scripts/e2e/reset-complex-agent-task.mjs new file mode 100644 index 000000000..5d90da860 --- /dev/null +++ b/skills/scripts/e2e/reset-complex-agent-task.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +import { cp, mkdir, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { env } from "node:process"; +import { spawnSync } from "node:child_process"; +import { + ensureEvidence, + evidencePaths, + loadEnvFiles, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +await loadEnvFiles(); +const paths = evidencePaths("reset-complex-agent-task"); +await ensureEvidence(paths); + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const source = resolve( + scriptDir, + "../../skills/langbot-testing/fixtures/complex-agent-task/workspace", +); +const repo = env.LANGBOT_REPO || ""; +const target = repo ? resolve(repo, "data/box/default/order-orchestrator") : ""; +const result = { + source: "setup_automation", + case_id: "reset-complex-agent-task", + run_id: paths.runId, + status: "fail", + reason: "", + target, + initial_test_exit_code: null, + evidence_collected: ["filesystem"], +}; + +try { + if (!repo) throw new Error("LANGBOT_REPO is required."); + await rm(target, { recursive: true, force: true }); + await mkdir(dirname(target), { recursive: true }); + await cp(source, target, { recursive: true }); + + const baseline = spawnSync( + "python3", + ["-m", "unittest", "discover", "-s", "tests", "-v"], + { cwd: target, encoding: "utf8", timeout: 60_000 }, + ); + result.initial_test_exit_code = baseline.status; + result.initial_failure_preview = `${baseline.stdout || ""}\n${baseline.stderr || ""}`.slice(0, 4000); + if (baseline.error) throw baseline.error; + if (baseline.status === 0) throw new Error("Complex task baseline unexpectedly passes; the fixture must start failing."); + + result.status = "pass"; + result.reason = "Complex task workspace reset and failing baseline confirmed."; +} catch (error) { + result.status = /required|ENOENT/.test(error.message) ? "env_issue" : "fail"; + result.reason = error.message; +} + +await writeResult(paths, result); +console.log(JSON.stringify(result, null, 2)); +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); diff --git a/skills/skills.index.json b/skills/skills.index.json index 2e047d737..e82a794b0 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -169,6 +169,7 @@ "langrag-sentinel-kb-discover", "local-agent-basic-debug-chat", "local-agent-combo-rag-compaction-tool-debug-chat", + "local-agent-complex-coding-task-debug-chat", "local-agent-context-compaction-debug-chat", "local-agent-effective-prompt-debug-chat", "local-agent-multimodal-debug-chat", @@ -1008,6 +1009,43 @@ "api_diagnostic" ] }, + { + "id": "local-agent-complex-coding-task-debug-chat", + "title": "Local Agent completes a multi-file coding task with iterative verification", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "agentic", + "coding", + "sandbox", + "tools", + "long-running" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/reset-complex-agent-task.mjs" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "backend_log", + "api_diagnostic", + "filesystem", + "metrics" + ] + }, { "id": "local-agent-context-compaction-debug-chat", "title": "Local Agent compacts long Debug Chat history and preserves older facts", diff --git a/skills/skills/langbot-testing/cases/local-agent-complex-coding-task-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-complex-coding-task-debug-chat.yaml new file mode 100644 index 000000000..c27d30367 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-complex-coding-task-debug-chat.yaml @@ -0,0 +1,95 @@ +id: local-agent-complex-coding-task-debug-chat +title: "Local Agent completes a multi-file coding task with iterative verification" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - agentic + - coding + - sandbox + - tools + - long-running +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_REPO + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_REPO + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" +automation_runner_config_patch_json: '{"knowledge-bases":[],"context-window-tokens":200000,"context-keep-recent-tokens":120000,"context-reserve-tokens":20000,"context-summary-tokens":12000,"max-tool-iterations":40,"tool-execution-mode":"serial","timeout":900}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_restore_runner_config: "1" +automation_restore_extensions: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" +automation_prompt: "Work autonomously on the project at /workspace/order-orchestrator. Read TASK.md completely, inspect the code and tests, then follow the required workflow. Run the full baseline test suite before editing and record the failing test names. Fix only production code under order_engine; do not alter TASK.md, tests, or acceptance_check.py. This is an end-to-end task: continue through failures, diagnose them, make multi-file fixes, rerun all 12 tests until green, run acceptance_check.py, and create both result.json and AGENT_REPORT.md exactly as required. Do not stop after analysis or ask me for confirmation. When and only when all verification passes, return exactly COMPLEX_AGENT_TASK_OK tests=12 acceptance=PASS." +automation_expected_text: "COMPLEX_AGENT_TASK_OK tests=12 acceptance=PASS" +automation_response_timeout_ms: "900000" +automation_stream_output: "1" +automation_filesystem_checks_json: '[{"argv":["python3","skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py","--workspace","${LANGBOT_REPO}/data/box/default/order-orchestrator"],"cwd":"${LANGBOT_REPO}","stdout_contains":"HOST_VERIFY_PASS tests=12 acceptance=PASS protected=PASS","exit_code":0,"timeout_ms":120000}]' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "node:scripts/e2e/reset-complex-agent-task.mjs" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The local Box workspace is mounted at LANGBOT_REPO/data/box/default and exposed to native tools as /workspace." + - "The selected real model supports sustained function calling across file inspection, edits, and test reruns." +steps: + - "Reset the isolated order-orchestrator workspace and confirm its baseline test suite fails." + - "Open the Local Agent pipeline in Debug Chat and send one complete autonomous coding task." + - "Observe the Agent inspect requirements and code, run the baseline tests, edit multiple production files, and rerun verification after failures." + - "Require the visible final sentinel only after all 12 tests and the acceptance check pass." + - "Run the host-side verifier to confirm protected files were not changed and required artifacts are valid." +checks: + - "UI: A new Bot message contains exactly COMPLEX_AGENT_TASK_OK tests=12 acceptance=PASS." + - "Run ledger: The real model performs a sustained sequence of native read, exec, and edit/write tool calls rather than a short answer." + - "Filesystem: Host verification reruns all 12 tests and acceptance_check.py successfully." + - "Filesystem: TASK.md, tests/test_order_engine.py, and acceptance_check.py match the immutable fixture copies." + - "Artifacts: result.json and AGENT_REPORT.md contain the required completion evidence." + - "API diagnostic: The temporary runner config uses a 200000-token context window and restores the prior config after the run." +evidence_required: + - ui + - screenshot + - console + - network + - backend_log + - api_diagnostic + - filesystem + - metrics +diagnostics: + - "A generic explanation or early plan without filesystem artifacts is a product-path failure, even if the model response sounds plausible." + - "If the Agent edits protected tests, the host verifier fails even when the visible test command passes." + - "If the run times out, inspect agent_run_event to distinguish model latency from a stuck tool or runtime action." +success_patterns: + - "COMPLEX_AGENT_TASK_OK tests=12 acceptance=PASS" + - "HOST_VERIFY_PASS tests=12 acceptance=PASS protected=PASS" +failure_patterns: + - "Agent runner temporarily unavailable" + - "runner.timeout" + - "Action invoke_llm_stream call timed out" + - "Tool call iteration limit reached" +troubleshooting: + - local-agent-model-route-unavailable + - sandbox-native-tools-unavailable + - plugin-runtime-timeout + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml index 96d9da8b6..bf241ebd9 100644 --- a/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml @@ -30,6 +30,11 @@ automation_env: - LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_restore_extensions: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" automation_prompt: "Call the qa_plugin_echo tool with exactly this text: plugin-tool-ok-local-agent. Return only the tool result." automation_expected_text: "qa-plugin-smoke:plugin-tool-ok-local-agent" automation_response_timeout_ms: "180000" @@ -44,7 +49,7 @@ preconditions: steps: - "Install or enable the bundled qa-plugin-smoke fixture plugin." - "Confirm /api/v1/tools or the plugin detail shows qa_plugin_echo." - - "Confirm the fixture plugin is bound to the target pipeline through Extensions, or that all plugins are enabled." + - "Temporarily bind only langbot-team/LocalAgent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface." - "Open the target local-agent pipeline." - "Use runner Default or the pluginized langbot-team/LocalAgent runner." - "Select a model with function-calling ability that is known to work with tools in the current environment." @@ -52,6 +57,7 @@ steps: - "Send: Call the qa_plugin_echo tool with exactly this text: plugin-tool-ok-local-agent. Return only the tool result." checks: - "UI: Debug Chat bot response contains qa-plugin-smoke:plugin-tool-ok-local-agent." + - "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound." - "Logs: Backend logs show the plugin tool call was executed, not only listed." - "Console: No unexpected frontend errors appear during Debug Chat." evidence_required: diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py new file mode 100644 index 000000000..006bfad67 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--workspace", required=True) + args = parser.parse_args() + workspace = Path(args.workspace).resolve() + baseline = Path(__file__).resolve().parent / "workspace" + + for relative in ("TASK.md", "tests/test_order_engine.py", "acceptance_check.py"): + if (workspace / relative).read_bytes() != (baseline / relative).read_bytes(): + raise AssertionError(f"protected fixture changed: {relative}") + + tests = subprocess.run( + [sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"], + cwd=workspace, + text=True, + capture_output=True, + timeout=60, + ) + if tests.returncode != 0: + raise AssertionError(f"unit tests failed:\n{tests.stdout}\n{tests.stderr}") + + acceptance = subprocess.run( + [sys.executable, "acceptance_check.py"], + cwd=workspace, + text=True, + capture_output=True, + timeout=30, + ) + if acceptance.returncode != 0 or "ACCEPTANCE_PASS" not in acceptance.stdout: + raise AssertionError(f"acceptance failed:\n{acceptance.stdout}\n{acceptance.stderr}") + + result = json.loads((workspace / "result.json").read_text(encoding="utf-8")) + assert result["status"] == "pass" + assert result["tests_run"] == 12 + assert result["acceptance"] == "PASS" + report = (workspace / "AGENT_REPORT.md").read_text(encoding="utf-8") + folded_report = report.casefold() + assert "initial" in folded_report and "fail" in folded_report, "report missing initial failure section" + for heading in ("root causes", "changed files", "verification"): + assert heading in folded_report, f"report missing section: {heading}" + assert report.rstrip().endswith("COMPLEX_AGENT_TASK_OK tests=12 acceptance=PASS") + print("HOST_VERIFY_PASS tests=12 acceptance=PASS protected=PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/TASK.md b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/TASK.md new file mode 100644 index 000000000..223c61b00 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/TASK.md @@ -0,0 +1,52 @@ +# Order Orchestrator Repair Task + +Repair the production code in `order_engine/` so the project satisfies all requirements below. + +## Required workflow + +1. Inspect the project and run the test suite before editing. +2. Record the initial failing test names in `AGENT_REPORT.md`. +3. Modify production code only. Do not edit `TASK.md`, `tests/`, or `acceptance_check.py`. +4. Re-run the full test suite after each logical fix until all 12 tests pass. +5. Run `python3 acceptance_check.py` and require `ACCEPTANCE_PASS`. +6. Write `result.json` and finish `AGENT_REPORT.md` with root causes, changed files, and verification commands. + +Use these commands from `/workspace/order-orchestrator`: + +```bash +python3 -m unittest discover -s tests -v +python3 acceptance_check.py +``` + +## Business rules + +- Monetary input must be converted through `Decimal(str(value))`. +- Every public monetary result is rounded to two decimals with `ROUND_HALF_UP`. +- Supported customer tiers are `standard` (0%), `vip` (10%), and `premium` (15%). Unknown tiers raise `ValueError`. +- Discount is calculated from the rounded subtotal. Tax is calculated from the subtotal after discount. Total is discounted subtotal plus tax. +- Every line quantity must be a positive integer. +- Duplicate SKUs are allowed, but inventory checks and decrements must use their aggregate quantity. +- Inventory reservation is atomic: no stock changes when any requested SKU is missing or insufficient. +- Processing the same successful `order_id` again is idempotent and returns the original result without decrementing stock again. +- Failed orders are not cached. A corrected retry with the same `order_id` must be processed normally. +- Batch processing continues after rejected orders and returns one result per input order. +- Reports sort orders by `order_id`, count accepted and rejected orders, and sum revenue from accepted orders only. +- Report money values are strings with exactly two decimal places. + +## Required artifacts + +`result.json` must contain at least: + +```json +{ + "status": "pass", + "tests_run": 12, + "acceptance": "PASS" +} +``` + +The final line of `AGENT_REPORT.md` must be: + +```text +COMPLEX_AGENT_TASK_OK tests=12 acceptance=PASS +``` diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/acceptance_check.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/acceptance_check.py new file mode 100644 index 000000000..da1c846b2 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/acceptance_check.py @@ -0,0 +1,26 @@ +from order_engine import Inventory, OrderLine, OrderProcessor, OrderRequest + + +def request(order_id, tier, tax, *lines): + return OrderRequest(order_id, tier, tax, tuple(lines)) + + +inventory = Inventory({"BOOK": 6, "PEN": 3}) +processor = OrderProcessor(inventory) +orders = [ + request("c-3", "vip", "0.08", OrderLine("BOOK", 2, "12.50")), + request("c-2", "standard", "0", OrderLine("PEN", 2, "1.005"), OrderLine("PEN", 2, "1.005")), + request("c-1", "premium", "0.05", OrderLine("BOOK", 1, "20.00")), +] +results, report = processor.process_batch(orders) +assert [result.status for result in results] == ["accepted", "rejected", "accepted"] +assert report == { + "accepted": 2, + "rejected": 1, + "revenue": "42.15", + "orders": ["c-1", "c-2", "c-3"], +} +assert inventory.snapshot() == {"BOOK": 3, "PEN": 3} +assert processor.process(orders[0]) == results[0] +assert inventory.snapshot() == {"BOOK": 3, "PEN": 3} +print("ACCEPTANCE_PASS") diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/__init__.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/__init__.py new file mode 100644 index 000000000..19e56acf5 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/__init__.py @@ -0,0 +1,5 @@ +from .inventory import Inventory +from .models import OrderLine, OrderRequest, OrderResult +from .service import OrderProcessor + +__all__ = ["Inventory", "OrderLine", "OrderProcessor", "OrderRequest", "OrderResult"] diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/inventory.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/inventory.py new file mode 100644 index 000000000..19e528383 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/inventory.py @@ -0,0 +1,19 @@ +from collections.abc import Mapping + +from .models import OrderLine + + +class Inventory: + def __init__(self, stock: Mapping[str, int]): + self._stock = dict(stock) + + def snapshot(self) -> dict[str, int]: + return dict(self._stock) + + def reserve(self, lines: tuple[OrderLine, ...]) -> None: + for line in lines: + if line.sku not in self._stock: + raise ValueError(f"unknown sku: {line.sku}") + if self._stock[line.sku] < line.quantity: + raise ValueError(f"insufficient stock: {line.sku}") + self._stock[line.sku] -= line.quantity diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/models.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/models.py new file mode 100644 index 000000000..e4bf2f63f --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/models.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class OrderLine: + sku: str + quantity: int + unit_price: Decimal | str | int | float + + +@dataclass(frozen=True) +class OrderRequest: + order_id: str + customer_tier: str + tax_rate: Decimal | str | int | float + lines: tuple[OrderLine, ...] + + +@dataclass(frozen=True) +class OrderResult: + order_id: str + status: str + subtotal: str = "0.00" + discount: str = "0.00" + tax: str = "0.00" + total: str = "0.00" + error: str = "" diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/pricing.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/pricing.py new file mode 100644 index 000000000..e93aa2621 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/pricing.py @@ -0,0 +1,26 @@ +from decimal import Decimal + +from .models import OrderLine + + +CENT = Decimal("0.01") +DISCOUNT_RATES = { + "standard": Decimal("0"), + "vip": Decimal("0.10"), + "premium": Decimal("0.15"), +} + + +def money(value: Decimal) -> Decimal: + return value.quantize(CENT) + + +def calculate_price( + lines: tuple[OrderLine, ...], customer_tier: str, tax_rate: Decimal | str | int | float +) -> tuple[Decimal, Decimal, Decimal, Decimal]: + discount_rate = DISCOUNT_RATES.get(customer_tier, Decimal("0")) + subtotal = money(sum((Decimal(line.unit_price) * line.quantity for line in lines), Decimal("0"))) + tax = money(subtotal * Decimal(tax_rate)) + discount = money((subtotal + tax) * discount_rate) + total = money(subtotal + tax - discount) + return subtotal, discount, tax, total diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/report.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/report.py new file mode 100644 index 000000000..8ddab1405 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/report.py @@ -0,0 +1,15 @@ +from decimal import Decimal + +from .models import OrderResult + + +def build_report(results: list[OrderResult]) -> dict[str, object]: + accepted = [result for result in results if result.status == "accepted"] + rejected = [result for result in results if result.status == "rejected"] + revenue = sum((Decimal(result.total) for result in results), Decimal("0")) + return { + "accepted": len(accepted), + "rejected": len(rejected), + "revenue": str(revenue), + "orders": [result.order_id for result in results], + } diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/service.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/service.py new file mode 100644 index 000000000..f5afe7614 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/order_engine/service.py @@ -0,0 +1,25 @@ +from .inventory import Inventory +from .models import OrderRequest, OrderResult +from .pricing import calculate_price +from .report import build_report + + +class OrderProcessor: + def __init__(self, inventory: Inventory): + self.inventory = inventory + + def process(self, order: OrderRequest) -> OrderResult: + self.inventory.reserve(order.lines) + subtotal, discount, tax, total = calculate_price(order.lines, order.customer_tier, order.tax_rate) + return OrderResult( + order_id=order.order_id, + status="accepted", + subtotal=str(subtotal), + discount=str(discount), + tax=str(tax), + total=str(total), + ) + + def process_batch(self, orders: list[OrderRequest]) -> tuple[list[OrderResult], dict[str, object]]: + results = [self.process(order) for order in orders] + return results, build_report(results) diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/tests/test_order_engine.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/tests/test_order_engine.py new file mode 100644 index 000000000..a2e0a3f7e --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/workspace/tests/test_order_engine.py @@ -0,0 +1,103 @@ +import unittest +from decimal import Decimal + +from order_engine import Inventory, OrderLine, OrderProcessor, OrderRequest +from order_engine.pricing import calculate_price +from order_engine.report import build_report +from order_engine.models import OrderResult + + +def order(order_id, lines, tier="standard", tax="0"): + return OrderRequest(order_id, tier, tax, tuple(lines)) + + +class PricingTests(unittest.TestCase): + def test_standard_total(self): + values = calculate_price((OrderLine("A", 2, "10.00"),), "standard", "0.08") + self.assertEqual(tuple(map(str, values)), ("20.00", "0.00", "1.60", "21.60")) + + def test_vip_discount_precedes_tax(self): + values = calculate_price((OrderLine("A", 2, "25.00"),), "vip", "0.10") + self.assertEqual(tuple(map(str, values)), ("50.00", "5.00", "4.50", "49.50")) + + def test_round_half_up_and_string_conversion(self): + values = calculate_price((OrderLine("A", 1, 1.005),), "standard", "0") + self.assertEqual(str(values[0]), "1.01") + + def test_unknown_tier_is_rejected(self): + with self.assertRaisesRegex(ValueError, "unknown customer tier"): + calculate_price((OrderLine("A", 1, "1"),), "wholesale", "0") + + +class InventoryTests(unittest.TestCase): + def test_reservation_is_atomic(self): + inventory = Inventory({"A": 3, "B": 1}) + with self.assertRaisesRegex(ValueError, "insufficient stock: B"): + inventory.reserve((OrderLine("A", 2, "1"), OrderLine("B", 2, "1"))) + self.assertEqual(inventory.snapshot(), {"A": 3, "B": 1}) + + def test_duplicate_skus_are_aggregated(self): + inventory = Inventory({"A": 3}) + with self.assertRaisesRegex(ValueError, "insufficient stock: A"): + inventory.reserve((OrderLine("A", 2, "1"), OrderLine("A", 2, "1"))) + self.assertEqual(inventory.snapshot(), {"A": 3}) + + def test_successful_reservation_decrements_aggregate(self): + inventory = Inventory({"A": 5}) + inventory.reserve((OrderLine("A", 2, "1"), OrderLine("A", 1, "1"))) + self.assertEqual(inventory.snapshot(), {"A": 2}) + + def test_quantity_must_be_positive_integer(self): + for bad_quantity in (0, -1, 1.5, True): + with self.subTest(quantity=bad_quantity): + inventory = Inventory({"A": 5}) + with self.assertRaisesRegex(ValueError, "positive integer"): + inventory.reserve((OrderLine("A", bad_quantity, "1"),)) + self.assertEqual(inventory.snapshot(), {"A": 5}) + + +class ServiceTests(unittest.TestCase): + def test_success_is_idempotent(self): + inventory = Inventory({"A": 5}) + processor = OrderProcessor(inventory) + request = order("same", [OrderLine("A", 2, "4.00")]) + self.assertEqual(processor.process(request), processor.process(request)) + self.assertEqual(inventory.snapshot(), {"A": 3}) + + def test_failed_order_is_not_cached(self): + inventory = Inventory({"A": 1}) + processor = OrderProcessor(inventory) + with self.assertRaises(ValueError): + processor.process(order("retry", [OrderLine("A", 2, "1")])) + accepted = processor.process(order("retry", [OrderLine("A", 1, "1")])) + self.assertEqual(accepted.status, "accepted") + self.assertEqual(inventory.snapshot(), {"A": 0}) + + def test_batch_continues_after_rejection(self): + processor = OrderProcessor(Inventory({"A": 2})) + results, report = processor.process_batch([ + order("ok-2", [OrderLine("A", 1, "3")]), + order("bad", [OrderLine("MISSING", 1, "5")]), + order("ok-1", [OrderLine("A", 1, "7")]), + ]) + self.assertEqual([item.status for item in results], ["accepted", "rejected", "accepted"]) + self.assertIn("unknown sku", results[1].error) + self.assertEqual(report["accepted"], 2) + self.assertEqual(report["rejected"], 1) + + def test_report_is_sorted_and_accepted_revenue_only(self): + report = build_report([ + OrderResult("z-order", "accepted", total="2.50"), + OrderResult("a-rejected", "rejected", total="999.00", error="no"), + OrderResult("m-order", "accepted", total="1.25"), + ]) + self.assertEqual(report, { + "accepted": 2, + "rejected": 1, + "revenue": "3.75", + "orders": ["a-rejected", "m-order", "z-order"], + }) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/skills/langbot-testing/references/local-agent-runner-coverage.md b/skills/skills/langbot-testing/references/local-agent-runner-coverage.md index 5fd62d8a2..f06b3b31f 100644 --- a/skills/skills/langbot-testing/references/local-agent-runner-coverage.md +++ b/skills/skills/langbot-testing/references/local-agent-runner-coverage.md @@ -38,6 +38,7 @@ These browser cases are the minimum gate for a local-agent migration check: | `local-agent-multimodal-debug-chat` | Image upload, structured input contents, and multimodal runner consumption | UI shows uploaded image and bot returns `IMAGE_OK`; backend receives an image input. | | `local-agent-rag-multimodal-debug-chat` | RAG insertion while structured image input is present | UI shows uploaded image, bot returns the KB sentinel, and backend logs the same request with `[Image]`. | | `local-agent-nonstreaming-debug-chat` | Debug Chat non-streaming UI delivery path | Bot returns `NONSTREAM_OK` with the Debug Chat stream switch disabled; runner-internal `invoke_llm` mode is covered by local-agent component tests using `runtime_metadata.streaming_supported=false`. | +| `local-agent-complex-coding-task-debug-chat` | Sustained native-tool planning, multi-file edits, iterative test recovery, and artifact production | Bot returns the completion sentinel only after host verification reruns 12 tests, acceptance, and protected-file checks. | ## Full Coverage Matrix diff --git a/skills/skills/langbot-testing/references/local-agent-runner.md b/skills/skills/langbot-testing/references/local-agent-runner.md index 0dc4d8274..8d829904b 100644 --- a/skills/skills/langbot-testing/references/local-agent-runner.md +++ b/skills/skills/langbot-testing/references/local-agent-runner.md @@ -36,6 +36,8 @@ If the direct MCP fixture passes but `/api/v1/tools` still shows the old MCP nam For a multimodal check, upload a small image and ask for a deterministic acknowledgement. Prefer the bundled 64x64 red-square fixture over a 1x1 image because some model providers reject tiny images before the runner path is exercised. +For sustained agentic behavior, run `local-agent-complex-coding-task-debug-chat`. It gives the runner one complete task in an isolated Box workspace, requires it to inspect a failing multi-file project, iterate on production fixes, rerun tests, and produce host-verifiable artifacts. Keep the normal context budget for this case; it is not a context-compaction or provider-concurrency probe. + For a Debug Chat non-streaming delivery check, disable the Debug Chat stream switch before sending the prompt. This validates the UI/adapter delivery path. Runner-internal non-streaming model invocation is covered by component tests that set `runtime_metadata.streaming_supported=false`. ## Timeout And Tool Regression Checks