From 350d2405b72c6e95aa60f76113b55f469f258137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 20 Oct 2026 16:44:09 +0800 Subject: [PATCH 01/16] fix(pipeline): preserve candidate batch identity on resume --- scripts/ci/README.md | 12 +- scripts/ci/run_e2e.py | 12 +- .../e2e/run_candidate_batch_recovery.py | 55 ++++++ src/iac_code/agent/agent_loop.py | 4 + tests/agent/test_agent_loop_new.py | 12 ++ .../test_candidate_batch_e2e.py | 187 ++++++++++++++++++ tests/scripts/test_ci_run_e2e.py | 4 +- 7 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 scripts/pipeline/e2e/run_candidate_batch_recovery.py create mode 100644 tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 08afe1e59..12f669fc8 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -13,7 +13,7 @@ uv run --no-sync python scripts/ci/run_e2e.py --suite full --jobs 3 uv run --no-sync python scripts/ci/run_e2e.py --list --suite all ``` -`fast` 包含 7 个 A2A、REPL、Web API 确定性契约用例;`full` 共 64 个,另含 A2A 执行控制与权限等待/恢复矩阵。`e3a-recovery` 保留运行中快照保存后的崩溃故障点,并验证用户手动继续后恢复同一任务;另设 `e3a-handoff-recovery` 验证已完成回合后的安全交接点重启,两者独立验收。Web API 用例明确使用 `--skip-browser`,不启动浏览器。只跑一个用例可用 `--case a2a-recovery-contract` 或 `--case a2a-handoff-recovery-contract`。`--jobs` 限定为 1–16,确定性套件默认 3,真实套件默认 12。每个用例用独立子进程、配置目录和日志目录;确定性用例不会继承常见 LLM 与阿里云凭证环境变量。 +`fast` 包含 7 个 A2A、REPL、Web API 确定性契约用例;`full` 共 66 个,另含 A2A 执行控制与权限等待/恢复矩阵。`e3a-recovery` 保留运行中快照保存后的崩溃故障点,并验证用户手动继续后恢复同一任务;另设 `e3a-handoff-recovery` 验证已完成回合后的安全交接点重启,两者独立验收。Web API 用例明确使用 `--skip-browser`,不启动浏览器。只跑一个用例可用 `--case a2a-recovery-contract` 或 `--case a2a-handoff-recovery-contract`。`--jobs` 限定为 1–16,确定性套件默认 3,真实套件默认 12。每个用例用独立子进程、配置目录和日志目录;确定性用例不会继承常见 LLM 与阿里云凭证环境变量。 权限批次新增四条 HTTP E2E,复用 `tests/a2a/test_permission_prompt_e2e.py`,仅模型与工具使用确定性替身。`permission-prompt-supersede-{same-process,after-restart}` 验证权限待确认时的新 prompt 取消整个未执行批次、补齐 tool_result,并阻止旧权限回复重新执行;`permission-confirmation-followup-{same-process,after-restart}` 验证连续正常确认权限后,工具只执行一次,下一条普通 prompt 正常处理,重复权限回复不会重跑工具。四条均纳入 `full`,也可单独执行: @@ -55,6 +55,14 @@ CI 可改用 `--cloud-credential-helper /path/to/helper.py`,此时源目录只 `live-readonly` 新增 `selector-restart-{normal,pipeline}-{sigterm,sigkill,released}-{selected,canceled}` 共 12 条,复用真实资源选择器重启脚本,查询已有 VPC,不创建云资源;隔离配置、模型分配、凭证刷新与安全报告沿用 selector runner。每条核对恢复后首个 SSE 帧的 execution 归属、消费回执以及下一轮普通 prompt。 +`ssf-candidate-batch-{same-process,cold-recovery}` 是 P18 的确定性回归/确认用例,纳入 `full`。使用生产 `selling_solution_first` 流程、AgentLoop、展示工具、会话文件和恢复逻辑,仅模型输出使用脚本替身:展示方案及详情 → 相同方案幂等重复展示 → 等待选择 → 用户选择方案。验证选择后直接进入模板生成阶段,保留原批次和所选方案,不再调用模型或要求重选;冷恢复用例重新构造 Runner/Storage 并从磁盘恢复,不模拟进程重启。用例在下一步骤启动时结束,不调用真实模型或云 API。 + +```bash +uv run --no-sync python scripts/ci/run_e2e.py \ + --case ssf-candidate-batch-same-process \ + --case ssf-candidate-batch-cold-recovery --jobs 1 --retries 0 +``` + ## 真实用例的模型池与思考强度 真实套件默认采用滚动调度:文本用例使用 `deepseek-v4-flash-0731`、`glm-5.2-fast-preview`、`glm-5.3-prime`、`deepseek-v4.1-flash`,每模型最多两个用例;多模态用例使用 `qwen3.8-max`、`qwen3.8-max-0902`、`qwen3.8-flash`、`qwen3.8-omni-flash`,每模型最多一个用例。两类模型池不能互相借用,总共最多 12 个用例。仅有文本用例待运行时最多八个;仅有多模态用例时最多四个。 @@ -102,7 +110,7 @@ REPL 旧场景的 PTY 等待每 10 秒检查一次,每 60 秒在单用例的 s 先按 `report.md` 找到失败用例与首次失败检查,再看对应的 `ci-result.json`、场景 `summary.json`、日志和源码。Agent 应做有界复现,明确归类为产品缺陷、用例/断言缺陷、环境/凭证故障、云资源清理故障或超时,并写出证据、受影响场景和建议修复。配置的一次自动重跑之外,真实云用例再次复现前先核对残留资源与清理结果。报告里的“初步线索”只是索引,不能替代复盘结论。 -总入口目前登记 185 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 +总入口目前登记 187 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 ### 验收边界 diff --git a/scripts/ci/run_e2e.py b/scripts/ci/run_e2e.py index d26c9bd6f..04bcf8f69 100644 --- a/scripts/ci/run_e2e.py +++ b/scripts/ci/run_e2e.py @@ -209,7 +209,15 @@ class CloudCredentialSetupError(RuntimeError): ) for decision in ("allow_once", "deny", "prompt") ) -CASES = FAST_CASES + EXECUTION_CASES + PERMISSION_CASES +CANDIDATE_BATCH_CASES = tuple( + Case( + "ssf-candidate-batch-" + mode, + "scripts/pipeline/e2e/run_candidate_batch_recovery.py", + ("--mode", mode), 90, "full", + ) + for mode in ("same-process", "cold-recovery") +) +CASES = FAST_CASES + EXECUTION_CASES + PERMISSION_CASES + CANDIDATE_BATCH_CASES LIVE_SCRIPT = "scripts/pipeline/e2e/selling_solution_first/run_scenarios.py" SELLING_CIDR_POOLS = tuple(str(pool) for pool in ipaddress.IPv4Network("10.250.0.0/16").subnets(new_prefix=22)) if len(SELLING_SCENARIOS) > len(SELLING_CIDR_POOLS): @@ -396,7 +404,7 @@ def select_cases(args: argparse.Namespace) -> list[Case]: if args.suite == "fast": return list(FAST_CASES) if args.suite == "full": - return list(FAST_CASES + EXECUTION_CASES + PERMISSION_CASES) + return list(FAST_CASES + EXECUTION_CASES + PERMISSION_CASES + CANDIDATE_BATCH_CASES) if args.suite == "live": return list(LIVE_CASES) if args.suite.startswith("live-"): diff --git a/scripts/pipeline/e2e/run_candidate_batch_recovery.py b/scripts/pipeline/e2e/run_candidate_batch_recovery.py new file mode 100644 index 000000000..2cd6d3abc --- /dev/null +++ b/scripts/pipeline/e2e/run_candidate_batch_recovery.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Run the deterministic P18 selection regression through the shared CI entrypoint.""" + +from __future__ import annotations + +import argparse +import json +import os +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--mode", choices=("same-process", "cold-recovery"), required=True) + args = parser.parse_args() + run_dir = args.run_dir.resolve() + run_dir.mkdir(parents=True, exist_ok=True) + os.chdir(Path(__file__).resolve().parents[3]) + junit_path = run_dir / "pytest.xml" + node = "tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py::" + node += f"test_idempotent_outline_survives_selection_resume[{args.mode}]" + exit_code = pytest.main( + [ + "-q", + "--tb=short", + "-o", + "addopts=", + node, + "--basetemp=" + str(run_dir / "pytest"), + "--junitxml=" + str(junit_path), + ] + ) + try: + cases = ET.parse(junit_path).findall(".//testcase") + completed = len(cases) == 1 and not any( + case.find(tag) is not None for case in cases for tag in ("failure", "error", "skipped") + ) + except (OSError, ET.ParseError): + completed = False + passed = exit_code == 0 and completed + summary = { + "passed": passed, + "mode": args.mode, + "checks": {"unchanged candidate batch survives real pipeline persistence and selection": passed}, + } + (run_dir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index c506cc0a7..b61ea3a01 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -3049,6 +3049,10 @@ def _tool_result_block_metadata(processed: Any, result_metadata: dict[str, Any] ) from iac_code.tools.cloud.base_stack import persisted_stack_metadata + # Idempotent display calls retain the original batch across transcript recovery. + candidate_set_id = result_metadata.get("candidate_set_id") + if isinstance(candidate_set_id, str) and candidate_set_id: + metadata["candidate_set_id"] = candidate_set_id aliyun_http = sanitize_aliyun_http_metadata(result_metadata.get(ALIYUN_HTTP_METADATA_KEY)) if aliyun_http is not None: metadata[ALIYUN_HTTP_METADATA_KEY] = aliyun_http diff --git a/tests/agent/test_agent_loop_new.py b/tests/agent/test_agent_loop_new.py index cd9b35277..d24a5a67b 100644 --- a/tests/agent/test_agent_loop_new.py +++ b/tests/agent/test_agent_loop_new.py @@ -145,6 +145,18 @@ def test_tool_result_block_metadata_filters_render_metadata_to_json_safe_fields( json.dumps(metadata) +@pytest.mark.parametrize("candidate_set_id", ["outline-original", "", None, 123, {"id": "outline"}]) +def test_tool_result_block_metadata_preserves_only_valid_candidate_set_id(candidate_set_id): + metadata = AgentLoop._tool_result_block_metadata( + SimpleNamespace(is_externalized=False), + {"candidate_set_id": candidate_set_id, "idempotent": True, "unrelated": object()}, + ) + + expected = {"candidate_set_id": candidate_set_id} if candidate_set_id == "outline-original" else {} + assert metadata == expected + json.dumps(metadata) + + def _marked_aliyun_http(**changes): value = { "contract_version": "aliyun_body_v1", diff --git a/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py b/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py new file mode 100644 index 000000000..e2e0812e0 --- /dev/null +++ b/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py @@ -0,0 +1,187 @@ +"""Deterministic P18 regression through the real pipeline, tools and disk recovery.""" + +from contextlib import aclosing +from pathlib import Path + +import pytest + +from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.pipeline.engine.pipeline_runner import PipelineRunner +from iac_code.pipeline.engine.ui_contract import encode_selected_candidate +from iac_code.services.session_storage import SessionStorage +from iac_code.tools.base import ToolRegistry +from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + ToolResultEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration, pytest.mark.timeout(60)] +PIPELINE = Path(__file__).resolve().parents[3] / "src/iac_code/pipeline/selling_solution_first" +NAME = "Managed database" +BATCH_ID = "outline-original" +REPEAT_ID = "outline-idempotent-repeat" +OUTLINE = { + "candidates": [ + { + "candidate_name": NAME, + "summary": "One managed RDS instance in Hangzhou", + "total_monthly_cost": "CNY 800-1200/month", + "key_tradeoff": "Fixed monthly cost", + } + ], +} +DETAIL = { + "candidate_index": 0, + "candidate_name": NAME, + "applicable_scenarios": ["Managed relational database"], + "resource_intents": [{"product": "RDS", "action": "create", "role": "database"}], + "topology_graph": {"nodes": [{"id": "rds", "label": "RDS", "product": "RDS"}], "edges": []}, + "resource_inventory": [ + { + "resource_id": "rds", + "product": "RDS", + "purpose": "database", + "quantity": 1, + "lifecycle": "create", + } + ], + "cost_assumptions": ["cn-hangzhou"], + "cost_exclusions": [], + "cost_confidence": "medium", + "decision_notes": { + "why_recommended": ["Meets the managed database request"], + "problems_solved": ["Avoid manual database maintenance"], + "pros": ["Managed backups", "Managed monitoring"], + "cons": ["Fixed monthly cost"], + }, +} + + +class PlanningModel: + """Only model output is scripted; no display, persistence or recovery mocks.""" + + def __init__(self): + self.calls = 0 + + def get_model_name(self): + return "deterministic-p18-fixture" + + async def stream(self, messages, system, tools=None, max_tokens=8192): + script = [ + (BATCH_ID, "show_architecture_plan", OUTLINE), + ("candidate-detail", "show_candidate_detail", DETAIL), + (REPEAT_ID, "show_architecture_plan", OUTLINE), + ( + "wait-for-selection", + "complete_step", + { + "conclusion": { + "status": "awaiting_selection", + "intent": { + "cloud_platform": "aliyun", + "resource_intents": [{"product": "RDS", "action": "create", "source": "user"}], + "hard_constraints": [], + }, + }, + }, + ), + # If deterministic selection falls back, attempt the same valid + # choice through the model so the real rejection is visible. + ( + "select-candidate", + "complete_step", + { + "conclusion": {"status": "selected", "selected_candidate_index": 0}, + }, + ), + ] + assert self.calls < len(script), ( + "Valid selection fell back to the model instead of advancing", + [ + (block.tool_use_id, block.content) + for message in messages + if isinstance(message.content, list) + for block in message.content + if block.type == "tool_result" + ], + ) + call_id, name, arguments = script[self.calls] + self.calls += 1 + yield MessageStartEvent(message_id=f"planning-{self.calls}") + yield ToolUseStartEvent(tool_use_id=call_id, name=name) + yield ToolUseEndEvent(tool_use_id=call_id, name=name, input=arguments) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + +@pytest.mark.parametrize("cold", [False, True], ids=["same-process", "cold-recovery"]) +async def test_idempotent_outline_survives_selection_resume(tmp_path, monkeypatch, cold): + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + workspace = tmp_path / "workspace" + workspace.mkdir() + model = PlanningModel() + + def runner(*, resume=False): + return PipelineRunner( + pipeline_dir=PIPELINE, + provider_manager=model, + base_tool_registry=ToolRegistry(), + session_storage=SessionStorage(tmp_path / "projects"), + session_id="p18-regression", + cwd=str(workspace), + resume_from_sidecar=resume, + ) + + flow = runner() + events = [event async for event in flow.run("Plan a managed RDS database in Hangzhou")] + results = {event.tool_use_id: event for event in events if isinstance(event, ToolResultEvent)} + assert set(results) == {BATCH_ID, "candidate-detail", REPEAT_ID, "wait-for-selection"} + assert not any(result.is_error for result in results.values()), results + assert results[REPEAT_ID].metadata["idempotent"] is True + assert results[REPEAT_ID].metadata["candidate_set_id"] == BATCH_ID + saved = flow.context.get_conclusion("solution_selection") + assert saved["status"] == "awaiting_selection" + assert saved["candidate_set_id"] == BATCH_ID + assert any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.USER_INPUT_REQUIRED for event in events + ) + assert model.calls == 4 + + if cold: + # Construct a new runner/storage: restore the real sidecar and attempt + # transcript from disk, without carrying the live completion guard. + flow = runner(resume=True) + assert flow.context.get_conclusion("solution_selection") == saved + + resumed = [] + async with aclosing(flow.resume(encode_selected_candidate(NAME, 0))) as stream: + async for event in stream: + resumed.append(event) + if isinstance(event, ToolResultEvent): + assert not event.is_error, event.result + if ( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.STEP_STARTED + and event.step_id == "materialize_selected_candidate" + ): + break # The selection boundary is verified; do not create cloud resources. + + selected = flow.context.get_conclusion("solution_selection") + assert selected["status"] == "selected", ( + "P18: an unchanged candidate batch was rejected after transcript recovery", + resumed, + ) + assert selected["candidate_set_id"] == BATCH_ID + assert selected["selected_candidate_index"] == 0 + assert selected["selected_candidate"] == saved["candidates"][0] + assert model.calls == 4, "Selection must not regenerate candidates or call the model again" + assert any( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.STEP_STARTED + and event.step_id == "materialize_selected_candidate" + for event in resumed + ) diff --git a/tests/scripts/test_ci_run_e2e.py b/tests/scripts/test_ci_run_e2e.py index 49270a7cc..3d96bb8ec 100644 --- a/tests/scripts/test_ci_run_e2e.py +++ b/tests/scripts/test_ci_run_e2e.py @@ -166,9 +166,9 @@ def test_default_selection_is_allowlisted_and_credential_free() -> None: def test_catalog_includes_headless_surfaces_and_excludes_browser_desktop() -> None: full = run_e2e.select_cases(run_e2e.parse_args(["--suite", "full", "--list"])) live = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live", "--list"])) - assert len(full) == 64 + assert len(full) == 66 assert len(live) == 121 - assert len(run_e2e.CASES) == 185 + assert len(run_e2e.CASES) == 187 agui = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live-agui", "--list"])) assert {case.name for case in agui} == {"agui-selector-" + name for name in run_e2e.AGUI_SCENARIOS} assert len(agui) == 6 From 72f014ce7dfe059cbbe6c7e36882fe85163c131d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 20 Oct 2026 17:43:35 +0800 Subject: [PATCH 02/16] feat(pipeline): add opt-in ROS assistant handoff guidance --- scripts/ci/README.md | 13 +- scripts/ci/run_e2e.py | 33 ++- scripts/ci/run_ros_agent_awareness.py | 199 ++++++++++++++++++ src/iac_code/pipeline/engine/loader.py | 2 + src/iac_code/pipeline/engine/step_executor.py | 5 +- src/iac_code/pipeline/engine/step_spec.py | 2 + .../hooks/solution_planning_and_selection.py | 26 ++- .../skills/iac-aliyun-solution-first/SKILL.md | 2 +- .../ros-agent-awareness.md | 1 + tests/pipeline/engine/test_step_executor.py | 38 ++++ .../test_ros_agent_awareness.py | 197 +++++++++++++++++ tests/scripts/test_ci_run_e2e.py | 28 ++- 12 files changed, 527 insertions(+), 19 deletions(-) create mode 100644 scripts/ci/run_ros_agent_awareness.py create mode 100644 src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md create mode 100644 tests/pipeline/selling_solution_first/test_ros_agent_awareness.py diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 12f669fc8..23664b8e9 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -35,7 +35,7 @@ uv run --no-sync python scripts/ci/run_e2e.py --suite live --jobs 4 \ --allow-cloud-write ``` -默认三文件模式下,凭证目录须含 `.credentials.yml`、`.cloud-credentials.yml`、`settings.yml`。建议使用专用测试账号、受限权限和资源配额。`live` 共 121 个:42 个 selling flow A2A/REPL 场景、20 个 A2A 只读资源选择(含 12 个重启恢复矩阵)、6 个 AGUI HTTP/SSE 资源选择、16 个 REPL 旧场景、33 个 A2A 恢复场景、3 个 VPC 模板 smoke、1 个只读云 API canary。其中包含真实 ROS 资源创建用例。需要缩小范围时可选 `live-core`、`live-recovery`、`live-multimodal`、`live-readonly`、`live-legacy`、`live-safety`、`live-repl`、`live-smoke` 或 `live-agui`,也可用 `--case` 指定单个用例。AGUI 的 6 条覆盖普通聊天与 Pipeline 的选择、取消和直接输入,通过 HTTP/SSE 运行,不需要浏览器;使用文本模型池,查询已有 VPC,不创建资源,清理状态为“无需清理”。可单独运行 `--suite live-agui`,或指定 `--case agui-selector-normal-selected`。三个 VPC smoke 只接收 LLM 与 settings 配置,使用隔离的 HOME,不接收云凭证;它们验证生成模板,不创建 VPC。并行 selling 场景各用独立的 `10.250.0.0/16` 子网池,避免独立进程重复预留同一 VSwitch CIDR;原 runner 单独运行仍沿用原池。旧 A2A、REPL 和 selling 场景不强制测试 StackName;清理读取隔离会话中真实 CreateStack 接受回执,按实际 ID、名称和地域校验后删除。仅查询、等待或模型提及的资源不能授权删除;硬超时后由独立清理进程再次尝试。selling、REPL 旧用例和 A2A 恢复用例的硬超时为 45 分钟,之后最多留 15 分钟清理,再强制结束进程组。硬超时不代表清理成功,需检查报告和测试账号残留资源。 +默认三文件模式下,凭证目录须含 `.credentials.yml`、`.cloud-credentials.yml`、`settings.yml`。建议使用专用测试账号、受限权限和资源配额。`live` 共 129 个:42 个 selling flow A2A/REPL 场景、20 个 A2A 只读资源选择(含 12 个重启恢复矩阵)、6 个 AGUI HTTP/SSE 资源选择、16 个 REPL 旧场景、33 个 A2A 恢复场景、3 个 VPC 模板 smoke、1 个只读云 API canary、8 个 ROS 助手引导真实模型场景。其中包含真实 ROS 资源创建用例。需要缩小范围时可选 `live-core`、`live-recovery`、`live-multimodal`、`live-readonly`、`live-legacy`、`live-safety`、`live-repl`、`live-smoke` 或 `live-agui`,也可用 `--case` 指定单个用例。AGUI 的 6 条覆盖普通聊天与 Pipeline 的选择、取消和直接输入,通过 HTTP/SSE 运行,不需要浏览器;使用文本模型池,查询已有 VPC,不创建资源,清理状态为“无需清理”。可单独运行 `--suite live-agui`,或指定 `--case agui-selector-normal-selected`。三个 VPC smoke 只接收 LLM 与 settings 配置,使用隔离的 HOME,不接收云凭证;它们验证生成模板,不创建 VPC。并行 selling 场景各用独立的 `10.250.0.0/16` 子网池,避免独立进程重复预留同一 VSwitch CIDR;原 runner 单独运行仍沿用原池。旧 A2A、REPL 和 selling 场景不强制测试 StackName;清理读取隔离会话中真实 CreateStack 接受回执,按实际 ID、名称和地域校验后删除。仅查询、等待或模型提及的资源不能授权删除;硬超时后由独立清理进程再次尝试。selling、REPL 旧用例和 A2A 恢复用例的硬超时为 45 分钟,之后最多留 15 分钟清理,再强制结束进程组。硬超时不代表清理成功,需检查报告和测试账号残留资源。 CI 可改用 `--cloud-credential-helper /path/to/helper.py`,此时源目录只需 LLM 凭证和 `settings.yml`。Helper 接口为 `cloud --output <目标 .cloud-credentials.yml 路径>`;它在每个真实云用例开始前调用,长用例每 10 分钟调用一次,异常清理前也会再次调用。若 helper 需要独立 Python 环境,传 `--cloud-credential-python /path/to/python`。Helper 必须原子地写入私有权限文件,且不得在标准输出或错误输出打印凭证。模板 smoke 不调用 helper。本地原有的三文件运行方式继续支持。 @@ -63,6 +63,15 @@ uv run --no-sync python scripts/ci/run_e2e.py \ --case ssf-candidate-batch-cold-recovery --jobs 1 --retries 0 ``` +`ros-awareness-*` 纳入 `live-readonly`,验证生产 Step 1 引擎和工具在真实模型下的分流:优惠、续费、计费、GPU 库存价格、流量抵扣、默认地域,以及开关关闭、部署成本咨询两个对照场景。验收用户可见链接、地域、持久化结束原因及澄清边界;不替换模型输出,不覆盖 HTTP/UI,不调用云 API。只复制 LLM 凭证和 settings,不接收云凭证或调用云凭证 helper。可定向运行: + +```bash +uv run --no-sync python scripts/ci/run_e2e.py \ + --case ros-awareness-renewal --case ros-awareness-default-region \ + --case ros-awareness-disabled --case ros-awareness-deployment-pricing \ + --credential-source-dir /path/to/test-config --jobs 2 --retries 0 +``` + ## 真实用例的模型池与思考强度 真实套件默认采用滚动调度:文本用例使用 `deepseek-v4-flash-0731`、`glm-5.2-fast-preview`、`glm-5.3-prime`、`deepseek-v4.1-flash`,每模型最多两个用例;多模态用例使用 `qwen3.8-max`、`qwen3.8-max-0902`、`qwen3.8-flash`、`qwen3.8-omni-flash`,每模型最多一个用例。两类模型池不能互相借用,总共最多 12 个用例。仅有文本用例待运行时最多八个;仅有多模态用例时最多四个。 @@ -110,7 +119,7 @@ REPL 旧场景的 PTY 等待每 10 秒检查一次,每 60 秒在单用例的 s 先按 `report.md` 找到失败用例与首次失败检查,再看对应的 `ci-result.json`、场景 `summary.json`、日志和源码。Agent 应做有界复现,明确归类为产品缺陷、用例/断言缺陷、环境/凭证故障、云资源清理故障或超时,并写出证据、受影响场景和建议修复。配置的一次自动重跑之外,真实云用例再次复现前先核对残留资源与清理结果。报告里的“初步线索”只是索引,不能替代复盘结论。 -总入口目前登记 187 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 +总入口目前登记 195 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 ### 验收边界 diff --git a/scripts/ci/run_e2e.py b/scripts/ci/run_e2e.py index 04bcf8f69..82387d89d 100644 --- a/scripts/ci/run_e2e.py +++ b/scripts/ci/run_e2e.py @@ -26,6 +26,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] CLOUD_REFRESH_SECONDS = 600 +LLM_ONLY_RUNNERS = frozenset({"smoke", "ros_awareness"}) SAFE_LIVE_AUDIT_NOTE = re.compile( r"credential audit: source=(?:llm|cloud); " r"location=(?:logs|artifacts|workspace|templates|other); " @@ -90,6 +91,7 @@ from scripts.a2a.e2e.run_recovery_scenarios import MULTIMODAL_SCENARIOS as A2A_MULTIMODAL_SCENARIOS # noqa: E402 from scripts.ci.live_diagnostics import collect_live_diagnostics # noqa: E402 from scripts.ci.model_pool import MULTIMODAL_MODELS, TEXT_MODELS, ModelAssignment, scheduled_cases # noqa: E402 +from scripts.ci.run_ros_agent_awareness import SCENARIOS as ROS_AWARENESS_SCENARIOS # noqa: E402 from scripts.pipeline.e2e.selling_solution_first.run_scenarios import SCENARIOS as SELLING_SCENARIOS # noqa: E402 from scripts.repl.e2e.run_pipeline_scenarios import _SCENARIOS as REPL_PIPELINE_SCENARIOS # noqa: E402 from scripts.repl.e2e.run_pipeline_scenarios import MULTIMODAL_SCENARIOS as REPL_MULTIMODAL_SCENARIOS # noqa: E402 @@ -295,6 +297,12 @@ def _selling_group(spec: Any) -> str: "repl-aliyun-readonly-canary", "scripts/repl/e2e/run_real_aliyun_contract_canary.py", (), 900, "live", cleanup_grace=60, live_runner="canary", group="readonly", ), +) + tuple( + Case( + "ros-awareness-" + scenario, "scripts/ci/run_ros_agent_awareness.py", + ("--scenario", scenario), 240, "live", live_runner="ros_awareness", group="readonly", + ) + for scenario in ROS_AWARENESS_SCENARIOS ) + ( Case("smoke-a2a-vpc", "scripts/a2a/smoke/test_a2a_vpc.py", (), 800, "live", live_runner="smoke", group="smoke"), Case("smoke-acp-vpc", "scripts/acp/smoke/test_acp_vpc.py", (), 450, "live", live_runner="smoke", group="smoke"), @@ -381,7 +389,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.error("--cloud-credential-python must name an existing Python interpreter") if args.cloud_credential_python is not None and args.cloud_credential_helper is None: parser.error("--cloud-credential-python requires --cloud-credential-helper") - needs_cloud = any(case.live_runner != "smoke" for case in selected) + needs_cloud = any(case.live_runner not in LLM_ONLY_RUNNERS for case in selected) required_files = [".credentials.yml", "settings.yml"] if needs_cloud and args.cloud_credential_helper is None: required_files.append(".cloud-credentials.yml") @@ -501,7 +509,7 @@ def _read_summary(case_dir: Path, source: str) -> dict[str, Any] | None: def _live_cleanup_status(case: Case, summary: dict[str, Any] | None) -> str: - if case.live_runner in {"selector", "agui_selector", "canary", "legacy_a2a_readonly", "smoke"}: + if case.live_runner in {"selector", "agui_selector", "canary", "legacy_a2a_readonly"} | LLM_ONLY_RUNNERS: return "not-needed" if summary is None: return "unverified" @@ -1487,7 +1495,8 @@ def run_case( ) source_config_dir.mkdir(mode=0o700, exist_ok=True) filenames = ( - (".credentials.yml", "settings.yml") if case.live_runner == "smoke" or cloud_credential_helper is not None + (".credentials.yml", "settings.yml") + if case.live_runner in LLM_ONLY_RUNNERS or cloud_credential_helper is not None else (".credentials.yml", ".cloud-credentials.yml", "settings.yml") ) for filename in filenames: @@ -1501,9 +1510,11 @@ def run_case( command.extend(("--model", model_assignment.model)) if case.live_runner not in {"canary", "agui_selector"}: command.extend(("--provider", "dashscope")) - if case.live_runner != "smoke" and cloud_credential_helper is not None: + if case.live_runner not in LLM_ONLY_RUNNERS and cloud_credential_helper is not None: _prepare_cloud_credentials(cloud_credential_helper, source_config_dir, cloud_credential_python) - if case.live_runner != "smoke": + if case.live_runner == "ros_awareness": + command.append("--allow-real-llm") + elif case.live_runner not in LLM_ONLY_RUNNERS: command.append("--allow-real-cloud") if case.live_runner == "selling": command.extend( @@ -1518,7 +1529,9 @@ def run_case( command.extend(("--source-config-dir", str(source_config_dir))) elif case.live_runner == "repl": command.extend(("--source-config-dir", str(source_config_dir))) - if case in FAST_CASES or (case.suite == "live" and case.live_runner not in {"smoke", "agui_selector"}): + if case in FAST_CASES or ( + case.suite == "live" and case.live_runner not in LLM_ONLY_RUNNERS | {"agui_selector"} + ): command.extend(("--python", sys.executable)) case_env = _case_env(case_dir, case, case_user_id) case_env['IAC_CODE_E2E_CASES_DIR'] = str((run_dir / 'runs').resolve()) @@ -1529,7 +1542,7 @@ def run_case( if model_assignment is not None: case_env.update({"IAC_CODE_PROVIDER": "dashscope", "IAC_CODE_MODEL": model_assignment.model}) case_env["IAC_CODE_E2E_DIAGNOSIS_LOCK"] = str(run_dir.resolve() / ".diagnosis-slot") - if case.live_runner == "smoke": + if case.live_runner in LLM_ONLY_RUNNERS: isolated_home = case_dir / "home" isolated_home.mkdir(mode=0o700, exist_ok=True) case_env["HOME"] = str(isolated_home) @@ -1568,7 +1581,11 @@ def refresh_cloud_credentials() -> None: start_new_session=os.name != "nt", creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, ) - if cloud_credential_helper is not None and case.suite == "live" and case.live_runner != "smoke": + if ( + cloud_credential_helper is not None + and case.suite == "live" + and case.live_runner not in LLM_ONLY_RUNNERS + ): refresh_thread = threading.Thread(target=refresh_cloud_credentials, daemon=True) refresh_thread.start() try: diff --git a/scripts/ci/run_ros_agent_awareness.py b/scripts/ci/run_ros_agent_awareness.py new file mode 100644 index 000000000..8179d7b8a --- /dev/null +++ b/scripts/ci/run_ros_agent_awareness.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Exercise Step 1 routing with a real LLM, production tools and session storage. + +This stops at handoff or the first clarification boundary. It does not replace +model responses or call cloud APIs; transport/UI behavior is outside its scope. +Invoke through run_e2e.py to receive isolated LLM-only configuration. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +import uuid +from contextlib import aclosing +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Scenario: + prompt: str + region: str | None = "cn-beijing" + enabled: bool = True + handoff: bool = True + + +SCENARIOS = { + "promotions": Scenario("阿里云现在有什么优惠活动和优惠券?我只想咨询,暂时不部署资源。"), + "renewal": Scenario("已有的 ECS 快到期了,续费有折扣吗?"), + "billing": Scenario("按量付费的 ECS 是如何计费的?欠费后怎么办?"), + "gpu-availability": Scenario("北京地域的 GPU 实例现在有哪些库存和单价?暂时不部署。", region="cn-shanghai"), + "traffic-offset": Scenario("有没有能抵扣已有 ECS 公网流量费的流量包?"), + "default-region": Scenario("我只想咨询阿里云的续费优惠,暂时不创建资源。", region=None), + "disabled": Scenario("我只想咨询已有 ECS 的续费折扣。", enabled=False, handoff=False), + "deployment-pricing": Scenario( + "我想在阿里云部署一个网站,请先问清网站类型和访问规模再推荐方案,选型时也帮我比较按量与包月成本。", + handoff=False, + ), +} + + +async def run(args: argparse.Namespace) -> dict: + # Prefer this checkout even if the interpreter has another editable install. + repo = Path(__file__).resolve().parents[2] + sys.path.insert(0, str(repo / "src")) + from iac_code.config import get_active_provider_key, get_provider_config, load_credentials + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + from iac_code.providers.manager import ProviderManager + from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential, use_aliyun_credential + from iac_code.services.session_storage import SessionStorage + from iac_code.tools.base import ToolRegistry + from iac_code.types.stream_events import ( + AskUserQuestionEvent, + ErrorEvent, + MessageEndEvent, + TextDeltaEvent, + ToolResultEvent, + ToolUseStartEvent, + ) + + scenario = SCENARIOS[args.scenario] + os.environ["IAC_CODE_ROS_AGENT_AWARENESS"] = "true" if scenario.enabled else "false" + os.environ["IAC_CODE_CONFIG_BACKUP_DIR"] = str(args.run_dir / "backup") + provider = args.provider or get_active_provider_key() + if not provider: + raise ValueError("No configured LLM provider") + config = get_provider_config(provider) + model = args.model or config.get("model") + if not model: + raise ValueError("No configured LLM model") + manager = ProviderManager( + model=model, + credentials=load_credentials(model), + provider_key_override=provider, + provider_config_override={**config, "modelFallbackEnabled": False}, + ignore_llm_source=True, + ) + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + flow = PipelineRunner( + pipeline_dir=repo / "src/iac_code/pipeline/selling_solution_first", + provider_manager=manager, + # Step-local tools are registered by the production engine. Deliberately + # omit cloud tools: routing needs no cloud operation or cloud credential. + base_tool_registry=ToolRegistry(), + session_storage=SessionStorage(args.run_dir / "projects"), + session_id="ros-awareness-e2e-" + uuid.uuid4().hex, + cwd=str(workspace), + ) + text: list[str] = [] + tools: list[str] = [] + input_tokens = output_tokens = error_count = 0 + asked_question = False + credential = AliyunCredential(region_id=scenario.region) if scenario.region is not None else None + with use_aliyun_credential(credential): + async with aclosing(flow.run(scenario.prompt)) as events: + async for event in events: + if isinstance(event, TextDeltaEvent): + text.append(event.text) + elif isinstance(event, ToolUseStartEvent): + tools.append(event.name) + elif isinstance(event, MessageEndEvent): + input_tokens += event.usage.total_input_tokens + output_tokens += event.usage.output_tokens + elif isinstance(event, ErrorEvent) or isinstance(event, ToolResultEvent) and event.is_error: + error_count += 1 + elif isinstance(event, AskUserQuestionEvent): + asked_question = True + await flow.persist_pending_ask_user_question(event) + break + + visible = "".join(text) + conclusion = flow.context.get_conclusion("solution_selection") or {} + url = f"https://ros.console.aliyun.com/{scenario.region or DEFAULT_REGION}/chat" + checks = { + "real model returned token usage": input_tokens > 0 and output_tokens > 0, + "no model or tool errors": error_count == 0, + "no deployment selected": flow.context.get_conclusion("selected_plan") is None, + } + if scenario.handoff: + checks.update( + { + "same-region assistant link is visible": url in visible, + "only the expected assistant destination is used": visible.count("https://ros.console.aliyun.com/") + == visible.count(url) + > 0, + "guidance is saved in the early-exit conclusion": conclusion.get("status") == "rejected" + and conclusion.get("continue_pipeline") is False + and url in conclusion.get("rejection_reason", ""), + "handoff skips deployment questions and candidates": tools == ["complete_step"] and not asked_question, + } + ) + elif scenario.enabled: + checks.update( + { + "normal clarification remains available": asked_question and "ask_user_question" in tools, + "no assistant handoff": "https://ros.console.aliyun.com/" not in visible + and conclusion.get("status") != "rejected", + } + ) + else: + # The unchanged base flow permits clarification or a normal rejection + # when the user has already confirmed that no deployment is requested. + checks.update( + { + "disabled flow preserves clarification or normal early exit": asked_question + and "ask_user_question" in tools + or conclusion.get("status") == "rejected" + and bool(conclusion.get("rejection_reason")), + "disabled flow does not add an assistant destination": "https://ros.console.aliyun.com/" not in visible + and "https://ros.console.aliyun.com/" not in conclusion.get("rejection_reason", ""), + } + ) + return { + "scenario": args.scenario, + "status": "passed" if all(checks.values()) else "failed", + "cleanup_status": "not-needed", + "checks": checks, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scenario", choices=SCENARIOS, required=True) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model") + parser.add_argument("--provider") + parser.add_argument("--allow-real-llm", action="store_true") + parser.add_argument("--timeout", type=float, default=180) + args = parser.parse_args(argv) + if not args.allow_real_llm: + parser.error("This calls a real LLM; pass --allow-real-llm") + args.run_dir.mkdir(parents=True, exist_ok=True) + started = time.monotonic() + try: + summary = asyncio.run(asyncio.wait_for(run(args), timeout=args.timeout)) + except Exception as exc: + # Do not copy provider messages/HTTP payloads into CI reports. + summary = { + "scenario": args.scenario, + "status": "failed", + "cleanup_status": "not-needed", + "checks": {"scenario completed within budget": False}, + "error_type": type(exc).__name__, + } + summary["duration_seconds"] = round(time.monotonic() - started, 2) + (args.run_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + print(json.dumps(summary), flush=True) + return 0 if summary["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/iac_code/pipeline/engine/loader.py b/src/iac_code/pipeline/engine/loader.py index 6ed537c81..7e5e455ed 100644 --- a/src/iac_code/pipeline/engine/loader.py +++ b/src/iac_code/pipeline/engine/loader.py @@ -503,6 +503,8 @@ def _bind_hooks(steps: list[StepSpec], pipeline_dir: Path) -> None: module = _load_module_from_file(hook_path, f"pipeline_hook_{step.step_id}") if hasattr(module, "on_enter"): step.on_enter = module.on_enter + if hasattr(module, "build_prompt_context"): + step.prompt_context_builder = module.build_prompt_context if hasattr(module, "on_exit"): step.on_exit = module.on_exit if hasattr(module, "enrich_completion_input"): diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 90c0a375b..1a25e00f6 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -841,16 +841,19 @@ def _build_full_system_prompt(self, step: StepSpec, context: PipelineContext) -> prompt_file = step.prompt_file_for_surface(self._surface) prompt_path = self._pipeline_dir / prompt_file step_prompt = prompt_path.read_text(encoding="utf-8") if prompt_file else "" + runtime_context = step.prompt_context_builder() if step.prompt_context_builder is not None else {} rendered_step_prompt = render_prompt( step_prompt, context, step.context_fields, - extra_context={"step_config": step.config}, + extra_context={"step_config": step.config, **runtime_context}, ) skill_content = "" if step.skill: skill_content = self._resolve_skill_prompt(step.skill) or "" + if step.prompt_context_builder is not None: + skill_content = render_prompt(skill_content, context, [], extra_context=runtime_context) parts = [p for p in [base_prompt, skill_content, rendered_step_prompt] if p] return "\n\n---\n\n".join(parts) diff --git a/src/iac_code/pipeline/engine/step_spec.py b/src/iac_code/pipeline/engine/step_spec.py index 1f74f7247..ff3279195 100644 --- a/src/iac_code/pipeline/engine/step_spec.py +++ b/src/iac_code/pipeline/engine/step_spec.py @@ -77,6 +77,8 @@ class StepSpec: on_enter: Callable[[PipelineContext], None] | None = None on_exit: Callable[[PipelineContext, dict], None] | None = None completion_enricher: Callable[..., dict[str, Any]] | None = None + #: Per-step skill/prompt replacements; never added to base prompt sections. + prompt_context_builder: Callable[[], dict[str, Any]] | None = None on_resource_observed: Callable[..., object] | None = None on_rollback_cleanup_required: Callable[..., object] | None = None #: Optional pre-check for structured waiting-input payloads. Returning a message keeps the step waiting. diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py b/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py index cff6337ce..d80fe0261 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py @@ -3,7 +3,9 @@ from __future__ import annotations import copy +import os import re +from pathlib import Path from typing import Any from iac_code.i18n import _ @@ -15,7 +17,29 @@ latest_candidate_outline_batch, ) -__all__ = ["enrich_completion_input"] +__all__ = ["build_prompt_context", "enrich_completion_input"] + + +def build_prompt_context() -> dict[str, Any]: + """Inject the short skill supplement only when ROS awareness is enabled.""" + enabled = os.environ.get("IAC_CODE_ROS_AGENT_AWARENESS", "").strip().casefold() in {"1", "true", "yes", "on"} + if not enabled: + return {"ros_agent_awareness": ""} + + from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredentials + + # The credential loader prefers the current session's request-local override. + credential = AliyunCredentials.load() + region = (credential.region_id if credential is not None else "").strip() + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", region): + region = DEFAULT_REGION + destination = f"[AI Assistant](https://ros.console.aliyun.com/{region}/chat)" + supplement = ( + Path(__file__).resolve().parents[1] + / "skills/iac-aliyun-solution-first/ros-agent-awareness.md" + ).read_text(encoding="utf-8").strip() + return {"ros_agent_awareness": "\n\n" + supplement.replace("{ai_assistant_destination}", destination)} + # 说服力字段与各自的最少条目数。缺失或空数组时方案卡只剩一句概述,用户无从判断为什么该选它, # 因此这里和 completion_input_schema 一样把它们当硬要求,而不是可选补充。 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md index 22329822b..5f455ea7b 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md @@ -39,7 +39,7 @@ user_invocable: false - **medium**:用户描述了业务目标,可合理推断需要基础设施(如"我想做个在线商城") - **low**:描述极其模糊,是否需要基础设施尚不确定(如"我有个项目想上线") -置信度写入 `intent.confidence`。 +置信度写入 `intent.confidence`。{ros_agent_awareness} ## 澄清提问能力 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md new file mode 100644 index 000000000..7c7caf3ae --- /dev/null +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md @@ -0,0 +1 @@ +ROS console override: For standalone Alibaba Cloud billing, promotions/renewals, availability/pricing or traffic-offset queries, send a text reply with {ai_assistant_destination} first (exact URL; tool arguments alone are not a reply). Then call `complete_step` with `status: rejected` and that linked reply as `rejection_reason`. This overrides deployment clarification/reselection. Keep deployment-related questions here; do not promise discounts or session transfer. diff --git a/tests/pipeline/engine/test_step_executor.py b/tests/pipeline/engine/test_step_executor.py index d67993980..73abd809a 100644 --- a/tests/pipeline/engine/test_step_executor.py +++ b/tests/pipeline/engine/test_step_executor.py @@ -1650,6 +1650,44 @@ def test_empty_prompt_file_with_skill(self, tmp_path): class TestStepExecutorBaseSections: + def test_step_runtime_facts_render_in_skill_and_step_without_entering_base_sections(self, tmp_path): + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts/test.md").write_text("Task in {host.region}", encoding="utf-8") + calls = [] + + def runtime_facts(): + calls.append(True) + return {"host": {"region": "cn-beijing"}} + + step = StepSpec( + step_id="test", + conclusion_field="result", + forward=None, + prompt_file="prompts/test.md", + skill="test-skill", + prompt_context_builder=runtime_facts, + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"result": []}, + max_rollbacks=3, + skills={"test-skill": 'Skill in {host.region}; literal JSON: {"status": "ready"}'}, + base_prompt_sections=IncludeExcludeConfig(include=["identity"]), + ) + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + prompt = executor._build_full_system_prompt(step, PipelineContext({"result": []})) + base, skill, task = prompt.split("\n\n---\n\n") + assert "cn-beijing" not in base + assert 'Skill in cn-beijing; literal JSON: {"status": "ready"}' in skill + assert task == "Task in cn-beijing" + assert calls == [True] + def test_system_prompt_includes_base_sections(self, tmp_path): """Base sections appear before skill content in system prompt.""" (tmp_path / "prompts").mkdir(exist_ok=True) diff --git a/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py b/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py new file mode 100644 index 000000000..0f7448f1b --- /dev/null +++ b/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py @@ -0,0 +1,197 @@ +"""Offline contracts for optional ROS sales-page guidance and request-local links.""" + +import asyncio +import re +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from iac_code.agent.system_prompt import build_system_prompt +from iac_code.pipeline.engine.context import PipelineContext +from iac_code.pipeline.engine.loader import load_pipeline_dir +from iac_code.pipeline.engine.pipeline_runner import PipelineRunner +from iac_code.pipeline.engine.step_executor import StepExecutor +from iac_code.pipeline.selling_solution_first.hooks.solution_planning_and_selection import build_prompt_context +from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential, use_aliyun_credential +from iac_code.services.session_storage import SessionStorage +from iac_code.tools.base import ToolRegistry +from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + TextDeltaEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + +PIPELINE = Path(__file__).resolve().parents[3] / "src/iac_code/pipeline/selling_solution_first" + + +@pytest.fixture(autouse=True) +def isolate_cloud_configuration(monkeypatch, tmp_path): + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr("iac_code.services.providers.aliyun.DEFAULT_ALIYUN_CLI_CONFIG_PATH", str(tmp_path / "cli.json")) + for name in ("ALIBABA_CLOUD_ACCESS_KEY_ID", "ALIBABA_CLOUD_ACCESS_KEY_SECRET", "ALIBABA_CLOUD_SECURITY_TOKEN"): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def executor(tmp_path): + pipeline = load_pipeline_dir(PIPELINE) + return StepExecutor( + provider_manager=SimpleNamespace(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=PIPELINE, + cwd=str(tmp_path), + ) + + +@pytest.mark.parametrize("value", [None, "", "0", "false", "off", "no", "unexpected"]) +def test_disabled_awareness_does_not_add_handoff_or_read_credentials(monkeypatch, value): + if value is None: + monkeypatch.delenv("IAC_CODE_ROS_AGENT_AWARENESS", raising=False) + else: + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", value) + + def unexpected_read(): + raise AssertionError("Disabled feature must not read a request credential") + + monkeypatch.setattr("iac_code.services.providers.aliyun.AliyunCredentials.load", unexpected_read) + assert build_prompt_context() == {"ros_agent_awareness": ""} + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", " yes ", "on"]) +def test_enabled_awareness_uses_session_region(monkeypatch, value): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", value) + with use_aliyun_credential(AliyunCredential(region_id="cn-shanghai")): + supplement = build_prompt_context()["ros_agent_awareness"] + assert "[AI Assistant](https://ros.console.aliyun.com/cn-shanghai/chat)" in supplement + assert "{ai_assistant_destination}" not in supplement + assert len(supplement) <= 550, "Keep the optional guidance short" + assert re.search(r"[\u4e00-\u9fff]", supplement) is None + + +@pytest.mark.parametrize("region", ["", "../cn-beijing", "cn-beijing/chat?token=bad", "https://example.com"]) +def test_missing_or_invalid_session_region_uses_default(monkeypatch, region): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", region) + with use_aliyun_credential(AliyunCredential(region_id=region)): + supplement = build_prompt_context()["ros_agent_awareness"] + assert f"https://ros.console.aliyun.com/{DEFAULT_REGION}/chat" in supplement + assert "example.com" not in supplement + assert "token=bad" not in supplement + + +def test_no_configured_credential_uses_default_region(monkeypatch): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setattr("iac_code.services.providers.aliyun.AliyunCredentials.load", lambda: None) + assert f"https://ros.console.aliyun.com/{DEFAULT_REGION}/chat" in build_prompt_context()["ros_agent_awareness"] + + +def test_configured_region_is_used_without_a_session_override(monkeypatch): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.load", + lambda: AliyunCredential(region_id="cn-shenzhen"), + ) + assert "https://ros.console.aliyun.com/cn-shenzhen/chat" in build_prompt_context()["ros_agent_awareness"] + + +@pytest.mark.asyncio +async def test_pipeline_prompt_uses_each_concurrent_callers_console_region(executor, monkeypatch): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", "cn-hangzhou") + step = executor._pipeline.steps[0] + context = PipelineContext(executor._pipeline.context_dependencies) + + async def prompt_for(region): + with use_aliyun_credential(AliyunCredential(region_id=region)): + await asyncio.sleep(0) + return executor._build_full_system_prompt(step, context) + + beijing, shanghai = await asyncio.gather(prompt_for("cn-beijing"), prompt_for("cn-shanghai")) + for prompt, own, other in [(beijing, "cn-beijing", "cn-shanghai"), (shanghai, "cn-shanghai", "cn-beijing")]: + assert f"https://ros.console.aliyun.com/{own}/chat" in prompt + assert f"https://ros.console.aliyun.com/{other}/chat" not in prompt + assert "https://ros.console.aliyun.com/cn-hangzhou/chat" not in prompt + base, skill_and_step = prompt.split("\n\n---\n\n", 1) + assert "ros_agent_awareness" not in base + assert "https://ros.console.aliyun.com/" not in base + assert f"https://ros.console.aliyun.com/{own}/chat" in skill_and_step + assert "ROS console override" in skill_and_step + assert "ros_agent_awareness" not in prompt + assert "This overrides deployment clarification/reselection" in prompt + assert "with `status: rejected`" in prompt + + +def test_handoff_is_limited_to_planning_and_leaves_normal_prompts_alone(executor, monkeypatch, tmp_path): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", "cn-beijing") + context = PipelineContext(executor._pipeline.context_dependencies) + prompts = [executor._build_full_system_prompt(step, context) for step in executor._pipeline.steps] + assert "ROS console override" in prompts[0] + assert all("ROS console override" not in prompt for prompt in prompts[1:]) + assert "ROS console override" not in build_system_prompt(cwd=str(tmp_path)) + assert "Keep deployment-related questions here" in prompts[0] + assert "do not promise discounts or session transfer" in prompts[0] + + +def test_planning_keeps_existing_clarification_when_awareness_is_off(executor, monkeypatch): + monkeypatch.delenv("IAC_CODE_ROS_AGENT_AWARENESS", raising=False) + context = PipelineContext(executor._pipeline.context_dependencies) + prompt = executor._build_full_system_prompt(executor._pipeline.steps[0], context) + assert "ros_agent_awareness" not in prompt + assert "ROS console override" not in prompt + assert "https://ros.console.aliyun.com/" not in prompt + assert "对于非部署/非云资源输入,应通过 `ask_user_question`" in prompt + rendered_skill = prompt.split("\n\n---\n\n", 1)[1].rsplit("\n\n---\n\n", 1)[0] + original_skill = executor._resolve_skill_prompt(executor._pipeline.steps[0].skill) + assert rendered_skill == original_skill.replace("{ros_agent_awareness}", "") + + +@pytest.mark.asyncio +async def test_guidance_is_visible_and_finishes_the_real_pipeline_without_candidates(tmp_path, monkeypatch): + """Script the model response; exercise actual tool execution and early exit.""" + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", "cn-hangzhou") + guidance = "请前往 [AI 助手](https://ros.console.aliyun.com/cn-beijing/chat) 咨询按量付费。" + + class Model: + def get_model_name(self): + return "offline-ros-handoff" + + async def stream(self, messages, system, tools=None): + assert "ROS console override" in system + assert "https://ros.console.aliyun.com/cn-beijing/chat" in system + yield MessageStartEvent(message_id="consultation") + yield TextDeltaEvent(text=guidance) + yield ToolUseStartEvent(tool_use_id="handoff", name="complete_step") + yield ToolUseEndEvent( + tool_use_id="handoff", + name="complete_step", + input={"conclusion": {"status": "rejected", "rejection_reason": guidance}}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + flow = PipelineRunner( + pipeline_dir=PIPELINE, + provider_manager=Model(), + base_tool_registry=ToolRegistry(), + session_storage=SessionStorage(tmp_path / "projects"), + session_id="consultation-handoff", + cwd=str(tmp_path), + ) + with use_aliyun_credential(AliyunCredential(region_id="cn-beijing")): + events = [event async for event in flow.run("按量付费怎么计费?")] + assert "".join(event.text for event in events if isinstance(event, TextDeltaEvent)) == guidance + tools = [event.name for event in events if isinstance(event, ToolUseStartEvent)] + assert tools == ["complete_step"] + conclusion = flow.context.get_conclusion("solution_selection") + assert conclusion["status"] == "rejected" + assert conclusion["continue_pipeline"] is False + assert conclusion["rejection_reason"] == guidance + assert flow.context.get_conclusion("selected_plan") is None diff --git a/tests/scripts/test_ci_run_e2e.py b/tests/scripts/test_ci_run_e2e.py index 3d96bb8ec..5cdc9afaf 100644 --- a/tests/scripts/test_ci_run_e2e.py +++ b/tests/scripts/test_ci_run_e2e.py @@ -167,8 +167,8 @@ def test_catalog_includes_headless_surfaces_and_excludes_browser_desktop() -> No full = run_e2e.select_cases(run_e2e.parse_args(["--suite", "full", "--list"])) live = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live", "--list"])) assert len(full) == 66 - assert len(live) == 121 - assert len(run_e2e.CASES) == 187 + assert len(live) == 129 + assert len(run_e2e.CASES) == 195 agui = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live-agui", "--list"])) assert {case.name for case in agui} == {"agui-selector-" + name for name in run_e2e.AGUI_SCENARIOS} assert len(agui) == 6 @@ -231,6 +231,18 @@ def test_live_requires_complete_credential_source_and_write_opt_in(tmp_path: Pat assert len(run_e2e.select_cases(args)) == len(run_e2e.LIVE_CASES) +def test_ros_awareness_cases_are_live_llm_only_and_keep_all_scenarios(tmp_path: Path) -> None: + for name in (".credentials.yml", "settings.yml"): + (tmp_path / name).write_text("fixture", encoding="utf-8") + cases = [case for case in run_e2e.LIVE_CASES if case.live_runner == "ros_awareness"] + assert {case.args[1] for case in cases} == set(run_e2e.ROS_AWARENESS_SCENARIOS) + for case in cases: + args = run_e2e.parse_args(["--case", case.name, "--credential-source-dir", str(tmp_path)]) + assert run_e2e.select_cases(args) == [case] + assert case.group == "readonly" and not case.cloud_write + assert run_e2e._live_cleanup_status(case, None) == "not-needed" + + def test_live_accepts_llm_only_source_with_cloud_helper(tmp_path: Path) -> None: for name in (".credentials.yml", "settings.yml"): (tmp_path / name).write_text("fixture", encoding="utf-8") @@ -966,8 +978,9 @@ def test_live_adapter_uses_isolated_credentials_and_sanitized_result( assert len(scenarios[0].name.removeprefix("scenario-")) == 32 -def test_smoke_adapter_has_model_config_but_no_cloud_credentials( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize("runner", ["smoke", "ros_awareness"]) +def test_llm_only_adapter_has_model_config_but_no_cloud_credentials( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, runner: str ) -> None: script = tmp_path / "smoke.py" script.write_text( @@ -981,6 +994,7 @@ def test_smoke_adapter_has_model_config_but_no_cloud_credentials( "assert Path(os.environ['HOME']).is_relative_to(run_dir)\n" "assert Path(os.environ['USERPROFILE']).is_relative_to(run_dir)\n" "assert '--allow-real-cloud' not in sys.argv\n" + "assert ('--allow-real-llm' in sys.argv) == ('ros_awareness' == sys.argv[sys.argv.index('--runner') + 1])\n" "(run_dir / 'summary.json').write_text(" "json.dumps({'passed': True, 'checks': {'ok': True}}), encoding='utf-8')\n", encoding="utf-8", @@ -991,9 +1005,11 @@ def test_smoke_adapter_has_model_config_but_no_cloud_credentials( (source / ".cloud-credentials.yml").write_text("cloud-fixture", encoding="utf-8") (source / "settings.yml").write_text('{"activeProvider": "dashscope"}', encoding="utf-8") monkeypatch.setattr(run_e2e, "REPO_ROOT", tmp_path) - case = run_e2e.Case("smoke-vpc-fixture", "smoke.py", (), 5, "live", live_runner="smoke") + case = run_e2e.Case("llm-only-fixture", "smoke.py", ("--runner", runner), 5, "live", live_runner=runner) + helper = tmp_path / "must-not-run.py" + helper.write_text("raise AssertionError('LLM-only tests must not request cloud credentials')", encoding="utf-8") - result = run_e2e.run_case(case, tmp_path / "report", source) + result = run_e2e.run_case(case, tmp_path / "report", source, cloud_credential_helper=helper) assert result["status"] == "passed" assert result["cleanupStatus"] == "not-needed" From e2c695c26367c8812836e51ab695d5a44b5826fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Wed, 21 Oct 2026 14:05:52 +0800 Subject: [PATCH 03/16] feat(pipeline): add on-demand SWAS product guidance --- setup.py | 1 + .../selling/skills/iac-aliyun-cost/SKILL.md | 2 +- .../skills/iac-aliyun-deploying/SKILL.md | 2 +- .../iac-aliyun-template-generating/SKILL.md | 2 +- .../skills/iac-aliyun-deploying/SKILL.md | 2 +- .../SKILL.md | 2 +- .../skills/iac-aliyun-solution-first/SKILL.md | 3 + .../iac-aliyun-solution-first/references | 1 + .../skills/bundled/iac_aliyun/SKILL.md | 2 +- .../references/cloud-products/swas.md | 84 +++++++++++++++++++ .../selling_solution_first/test_packaging.py | 26 +++--- tests/skills/bundled/test_iac_skill.py | 3 +- tests/test_setup_packaging.py | 43 +++++++++- 13 files changed, 152 insertions(+), 21 deletions(-) create mode 120000 src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references create mode 100644 src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md diff --git a/setup.py b/setup.py index 521b41085..6d66c86ef 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ "iac-aliyun-deploying", ) SELLING_SOLUTION_FIRST_REFERENCE_SKILLS = ( + "iac-aliyun-solution-first", "iac-aliyun-deploying", "iac-aliyun-materialize-selected-candidate", ) diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md index da8f0ac80..22784ebee 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md @@ -277,7 +277,7 @@ ros_estimate_template_cost( | 文件 | 内容 | 何时查阅 | |------|------|----------| -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | 修复 Parameters 定义(缺少 AssociationProperty、Label 等)时 | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | 修复资源定义、内置函数用法等模板结构问题时 | diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md index 09f51eca4..2ad002df2 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md @@ -177,5 +177,5 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md index 5162088eb..4b0bde0be 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md @@ -113,5 +113,5 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md index eb7499a2f..6aa9a1b1b 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md @@ -178,7 +178,7 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md index eeeff3602..4268a3562 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md @@ -212,7 +212,7 @@ Preview 明确表明新建资源受配额、容量或账号限制时,先用 `a | 文件 | 内容 | 何时查阅 | |------|------|----------| -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md) | 需要了解产品属性、规格选型、库存相关字段时 | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | 生成或修复 Parameters 定义时 | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | 生成或修复资源定义、内置函数用法等模板结构问题时 | | [references/template-parameter-recommendation.md](references/template-parameter-recommendation.md) | 参数推荐与回溯规则、PreviewStack 参数集形成方法 | 求解库存/已有资源参数并形成预览参数集时 | diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md index 5f455ea7b..5683ca70a 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md @@ -161,6 +161,8 @@ user_invocable: false ## 第二部分:详细架构规划 +查阅 [references/cloud-products/](references/cloud-products/) 下对应产品文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md),了解选型策略、配置限制和计费方式;按需读取本次涉及的产品资料。 + ### 核心原则:按需设计,不过度发挥 方案数量取决于需求复杂度,而非固定出 2-3 个凑数: @@ -303,6 +305,7 @@ user_invocable: false - 只给区间,不要求精确到个位;费用估算基于阿里云公开定价的合理范围。 - 必须在 `assumptions` 中说明地域、计费方式、数量和规格假设。 +- 套餐型产品可复用上下文中的目标地域套餐参考信息,信息不足时按需查询;粗估不额外验证活动资格或逐套餐询价。说明价格来源和购买时长,不凭假设的促销价使方案满足预算;用户提供的活动价注明适用条件尚未确认。 - 不包含的费用必须显式列入 `exclusions`,例如公网流量、短信、日志写入量、跨地域流量。 - 无法形成可信估计时,使用较宽区间并标记 `confidence: low`,不得伪造精确金额。 - 有用户预算硬约束时,候选区间上限原则上不得超过预算;确实无法满足时先澄清或明确标记冲突,不得静默放宽预算。 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references new file mode 120000 index 000000000..9fc803476 --- /dev/null +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references @@ -0,0 +1 @@ +../../../selling/references \ No newline at end of file diff --git a/src/iac_code/skills/bundled/iac_aliyun/SKILL.md b/src/iac_code/skills/bundled/iac_aliyun/SKILL.md index 3da1b458e..d68ae4ad1 100644 --- a/src/iac_code/skills/bundled/iac_aliyun/SKILL.md +++ b/src/iac_code/skills/bundled/iac_aliyun/SKILL.md @@ -173,7 +173,7 @@ auto_trigger: |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组(ROS/Terraform 共用) | | [references/template-parameter-recommendation.md](references/template-parameter-recommendation.md) | 已有 ROS 可部署模板的部署前参数推荐流程 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | | [references/terraform-template.md](references/terraform-template.md) | Terraform 最佳实践:文件组织、变量、Data Source、ROS 集成 | diff --git a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md new file mode 100644 index 000000000..6294ac7ab --- /dev/null +++ b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md @@ -0,0 +1,84 @@ +# 阿里云轻量应用服务器(SWAS)选型指南 + +## 适用场景 + +- 适合个人网站、开发测试、小型 Web 和单机应用,优先考虑标准套餐及应用镜像,减少环境搭建工作。 +- 需要 GPU、复杂网络、弹性伸缩或更丰富实例规格时,选择 ECS。 +- SWAS 是独立产品,底层基于 ECS 的计算、存储和网络资源;API 产品标识为 `SWAS-OPEN`,套餐、镜像和防火墙使用 SWAS 自己的标识与接口。 + +## 套餐选择 + +根据目标地域下的实际套餐,选择满足应用要求的配置: + +| 维度 | 选择依据 | +|------|----------| +| 计算 | `Core`(vCPU)、`Memory`(GiB):满足应用及同机运行服务的 CPU、内存需求 | +| 存储 | `DiskSize`(GiB)、`DiskType`:系统、应用与数据所需容量及磁盘类型 | +| 网络 | `Bandwidth`(Mbps)、`Flow`(GB/月):峰值带宽与每月流量配额,结合访问量选择 | +| 操作系统 | `SupportPlatform`:套餐支持的 Linux / Windows 平台 | +| 价格 | `OriginPrice`、`Currency`:套餐包月参考价与币种 | + +以查询结果关联 `PlanId` 与规格,不根据套餐 ID 猜测配置。不同地域的套餐及镜像可能不同,不直接复用其他地域的选择。 + +`OriginPrice` 返回字符串金额,计算费用时按数值处理;`SupportPlatform` 是 JSON 数组形式的字符串(如 `["Linux","Windows"]`),需解析后匹配平台。`Flow` 可能缺失,不将缺失值直接解释为零流量或无限流量;结合当前套餐说明判断,信息不足时保留未知。 + +## 镜像选择 + +- 标准应用或容器环境:优先匹配应用镜像,例如 WordPress、Docker;核对镜像描述中的系统与组件版本是否适配应用。 +- 自定义技术栈:选择兼容的系统镜像,自行安装运行环境。 +- 复制已有环境:可使用轻量应用服务器的自定义镜像。 +- 使用 `ListImages` 返回的 SWAS `ImageId`,不要直接使用 ECS 镜像 ID,也不要把教程中的旧版本或示例 ID 当成当前可用值。 +- 镜像列表可能包含旧版本,优先选择仍受维护且满足应用依赖的版本,不按返回顺序直接取第一项。 + +## 存储与网络配置 + +- 套餐已包含系统盘和公网访问能力,不要为同一能力额外配置 ECS 系统盘或 EIP。 +- 应用数据超过系统盘容量时,可增加一块单独计费的数据盘;每台实例最多挂载一块,数据盘不能合并到系统分区。需要扩大系统盘时升级套餐。 +- 实例使用 SWAS 防火墙控制入流量。按最小授权原则配置业务端口与来源地址,不套用 ECS 安全组。 +- 流量政策按套餐区分:固定流量配额套餐可能产生超额流量费,无固定流量套餐按其计费说明处理;不要仅凭峰值带宽承诺无限流量。 + +## 计费与价格 + +- 套餐和数据盘按包年包月购买;适用的超额流量按量计费。 +- `ListPlans.OriginPrice` 是套餐的**包月价格**。参考总价可按「包月价格 × 购买月数 × 台数」折算,但该值不包含年付折扣、活动优惠或额外数据盘等费用。 +- 包月价、年付总价和活动价分别说明,比较时保持地域、套餐、购买时长、数量及费用范围一致。 +- 首购、学生、新人或续费等活动价格受活动条件约束,不凭规格相同就认定用户可享受同一优惠;实际费用以当前购买或询价结果为准。 + +## 套餐与镜像查询 + +### 查询目标地域的套餐 + +```python +aliyun_api(product="SWAS-OPEN", action="ListPlans", region_id="<目标地域>") +``` + +根据套餐字段筛选配置,保留对应的 `PlanId`、价格及币种。套餐列表描述规格和参考价格,不代表已经完成库存或创建可行性检查。 + +### 查询目标地域的镜像 + +```python +aliyun_api(product="SWAS-OPEN", action="ListImages", params={ + "ImageType": "system" +}, region_id="<目标地域>") +``` + +`ImageType` 可选 `system`(系统镜像)、`app`(应用镜像)或 `custom`(自定义镜像);根据镜像的 `Platform`、名称和描述匹配套餐平台与应用依赖。 + +## 最佳实践 + +- 应用镜像能够满足需求时,优先复用预装环境;自定义部署应匹配操作系统与依赖版本。 +- 网站或 API 仅开放必要业务入口;管理端口限制来源,数据库等内部服务避免直接向公网开放。 +- 重要数据保留备份;重置系统前备份系统盘数据,重置会清除原有系统盘内容。 +- 持续监控资源使用情况,容量不足时考虑升级套餐。套餐仅支持升配、不支持降配,也不支持单独升级 CPU、内存、系统盘或带宽;需要更复杂资源配置时评估迁移到 ECS。 + +## 官方参考 + +- [产品概述](https://help.aliyun.com/zh/simple-application-server/product-overview/what-is-simple-application-server) +- [与 ECS 对比](https://help.aliyun.com/zh/simple-application-server/product-overview/comparison-between-simple-application-server-and-ecs) +- [ListPlans:套餐信息与包月价格字段](https://help.aliyun.com/zh/simple-application-server/developer-reference/api-swas-open-2020-06-01-listplans) +- [ListImages:镜像信息](https://help.aliyun.com/zh/simple-application-server/developer-reference/api-swas-open-2020-06-01-listimages) +- [计费项](https://help.aliyun.com/zh/simple-application-server/product-overview/billable-items) +- [挂载数据盘及使用限制](https://help.aliyun.com/zh/simple-application-server/user-guide/attach-a-data-disk) +- [升级套餐配置及限制](https://help.aliyun.com/zh/simple-application-server/user-guide/upgrade-a-simple-application-server) +- [最佳实践:快速部署 Docker](https://help.aliyun.com/zh/simple-application-server/use-cases/deploy-and-use-docker) +- [管理防火墙](https://help.aliyun.com/zh/simple-application-server/user-guide/manage-the-firewall-of-a-server) diff --git a/tests/pipeline/selling_solution_first/test_packaging.py b/tests/pipeline/selling_solution_first/test_packaging.py index bf26814e3..29110be47 100644 --- a/tests/pipeline/selling_solution_first/test_packaging.py +++ b/tests/pipeline/selling_solution_first/test_packaging.py @@ -22,7 +22,11 @@ SELLING_REFERENCES = PROJECT_ROOT / "src" / "iac_code" / "pipeline" / "selling" / "references" SELLING_DEPLOYING_SKILL = PROJECT_ROOT / "src" / "iac_code" / "pipeline" / "selling" / "skills" / "iac-aliyun-deploying" BUNDLED_REFERENCES = PROJECT_ROOT / "src" / "iac_code" / "skills" / "bundled" / "iac_aliyun" / "references" -SKILLS_WITH_REFERENCES = ("iac-aliyun-deploying", "iac-aliyun-materialize-selected-candidate") +SKILLS_WITH_REFERENCES = ( + "iac-aliyun-solution-first", + "iac-aliyun-deploying", + "iac-aliyun-materialize-selected-candidate", +) # canonical 来源:只有 pipeline 自己的参数推荐规则来自 selling/references,其余来自 bundled 技能。 CANONICAL_ROOTS = { "template-parameter-recommendation.md": SELLING_REFERENCES, @@ -147,13 +151,9 @@ def test_references_are_byte_identical_to_the_canonical_content(self, skill_name assert path.read_bytes() == canonical.read_bytes(), path assert len(relative_files) >= 11 - def test_both_skills_resolve_the_same_reference_tree(self): - first, second = ( - _reference_target(PIPELINE_DIR / "skills" / name / "references") for name in SKILLS_WITH_REFERENCES - ) - - assert first == SELLING_REFERENCES.resolve() - assert second == SELLING_REFERENCES.resolve() + def test_all_skills_resolve_the_same_reference_tree(self): + for name in SKILLS_WITH_REFERENCES: + assert _reference_target(PIPELINE_DIR / "skills" / name / "references") == SELLING_REFERENCES.resolve() @pytest.mark.parametrize("skill_name", SKILLS_WITH_REFERENCES) def test_every_reference_link_in_the_skill_resolves(self, skill_name): @@ -168,12 +168,14 @@ def test_every_reference_link_in_the_skill_resolves(self, skill_name): relative = Path(link).relative_to("references") assert (references / relative).exists(), f"{skill_name}: {link}" - def test_step_one_skill_does_not_reference_template_references(self): + def test_step_one_links_product_guides_without_template_references(self): skill_dir = PIPELINE_DIR / "skills" / "iac-aliyun-solution-first" + text = (skill_dir / "SKILL.md").read_text(encoding="utf-8") + links = set(REFERENCE_LINK_PATTERN.findall(text)) - # Step 1 不生成模板,因此不引用 references/,也不需要复制一份参考目录。 - assert not (skill_dir / "references").exists() - assert "references/" not in (skill_dir / "SKILL.md").read_text(encoding="utf-8") + # Product knowledge is read on demand; template/pricing workflows stay in Step 2. + assert links == {"references/cloud-products/"} + assert "OriginPrice" not in text class TestEncoding: diff --git a/tests/skills/bundled/test_iac_skill.py b/tests/skills/bundled/test_iac_skill.py index 617ea6830..1d82d9c0a 100644 --- a/tests/skills/bundled/test_iac_skill.py +++ b/tests/skills/bundled/test_iac_skill.py @@ -149,7 +149,8 @@ def test_ecs_reference_requires_vswitch(self): def test_cloud_product_catalogs_list_all_bundled_product_references(self): product_names = {path.name for path in (IAC_SKILL_ROOT / "references" / "cloud-products").glob("*.md")} - for skill_path in [IAC_SKILL_ROOT / "SKILL.md", *SELLING_IAC_SKILLS]: + solution_first_skills = Path("src/iac_code/pipeline/selling_solution_first/skills").glob("*/SKILL.md") + for skill_path in [IAC_SKILL_ROOT / "SKILL.md", *SELLING_IAC_SKILLS, *solution_first_skills]: content = skill_path.read_text(encoding="utf-8") missing = sorted(name for name in product_names if name not in content) assert not missing, f"{skill_path} is missing cloud product references: {missing}" diff --git a/tests/test_setup_packaging.py b/tests/test_setup_packaging.py index 4d0e8d1cf..3c34bf741 100644 --- a/tests/test_setup_packaging.py +++ b/tests/test_setup_packaging.py @@ -4,6 +4,7 @@ import shutil from pathlib import Path +import pytest import setuptools try: @@ -12,6 +13,9 @@ import tomli as tomllib import iac_code +from iac_code.tools.base import ToolContext +from iac_code.tools.read_file import ReadFileTool +from iac_code.types.permissions import ToolPermissionContext PROJECT_ROOT = Path(__file__).resolve().parents[1] SOLUTION_FIRST_SKILLS = PROJECT_ROOT / "src/iac_code/pipeline/selling_solution_first/skills" @@ -60,7 +64,11 @@ def _assert_expanded_selling_references(package_root: Path, skill_names: tuple[s def _seed_solution_first_skill_files(package_root: Path) -> None: """Model build_py copying package data before setup materializes references.""" target_skills = package_root / "pipeline" / "selling_solution_first" / "skills" - for skill_name in ("iac-aliyun-deploying", "iac-aliyun-materialize-selected-candidate"): + for skill_name in ( + "iac-aliyun-solution-first", + "iac-aliyun-deploying", + "iac-aliyun-materialize-selected-candidate", + ): source = SOLUTION_FIRST_SKILLS / skill_name target = target_skills / skill_name target.mkdir(parents=True, exist_ok=True) @@ -87,11 +95,42 @@ def _assert_expanded_solution_first_resources(package_root: Path, setup_module) package_root, setup_module.SELLING_IAC_ALIYUN_SKILLS, ) - for references in (deploying / "references", materialize_references): + for skill_name in setup_module.SELLING_SOLUTION_FIRST_REFERENCE_SKILLS: + references = skills / skill_name / "references" assert references.is_dir() and not references.is_symlink() assert (references / "ros-template.md").is_file() assert (references / "cloud-products" / "ecs.md").is_file() assert (references / "solutions" / "iac-code-web.ros.yml").is_file() + assert (references / "cloud-products" / "swas.md").read_bytes() == ( + PROJECT_ROOT / "src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md" + ).read_bytes() + + +@pytest.mark.asyncio +async def test_installed_step_one_can_read_swas_guide_from_an_unrelated_workspace(tmp_path, monkeypatch): + setup_module = _load_setup_module(monkeypatch) + package_root = tmp_path / "installed" / "iac_code" + _seed_solution_first_skill_files(package_root) + setup_module._copy_selling_skill_references_to_package_root(package_root) + skill_root = package_root / "pipeline/selling_solution_first/skills/iac-aliyun-solution-first" + workspace = tmp_path / "workspace" + workspace.mkdir() + roots = [str(skill_root)] + tool_input = {"path": "references/cloud-products/swas.md"} + tool = ReadFileTool() + + permission = await tool.check_permissions( + tool_input, + ToolPermissionContext(cwd=str(workspace), trusted_read_directories=roots, relative_read_directories=roots), + ) + assert permission.behavior == "allow" + result = await tool.execute( + tool_input=tool_input, + context=ToolContext(cwd=str(workspace), trusted_read_directories=roots, relative_read_directories=roots), + ) + assert not result.is_error + assert "OriginPrice" in result.content + assert "包月价格" in result.content def test_selling_skill_references_are_expanded_for_installed_artifacts(monkeypatch, tmp_path): From b8a35551a33789b03087650bc9cd1b8c113903f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 22 Oct 2026 11:03:41 +0800 Subject: [PATCH 04/16] fix(aliyun): retry transient OpenMeta requests within a deadline --- .../i18n/locales/de/LC_MESSAGES/messages.po | 9 +- .../i18n/locales/es/LC_MESSAGES/messages.po | 9 +- .../i18n/locales/fr/LC_MESSAGES/messages.po | 9 +- .../i18n/locales/ja/LC_MESSAGES/messages.po | 7 +- .../i18n/locales/pt/LC_MESSAGES/messages.po | 9 +- .../i18n/locales/zh/LC_MESSAGES/messages.po | 7 +- src/iac_code/tools/cloud/aliyun/openmeta.py | 132 ++++++++--- .../tools/cloud/aliyun/public_errors.py | 8 +- .../tools/cloud/aliyun/test_aliyun_api_doc.py | 8 +- .../aliyun/test_aliyun_api_permissions.py | 4 +- tests/tools/cloud/aliyun/test_openmeta.py | 22 +- .../tools/cloud/aliyun/test_openmeta_retry.py | 215 ++++++++++++++++++ .../tools/cloud/aliyun/test_public_errors.py | 4 +- 13 files changed, 368 insertions(+), 75 deletions(-) create mode 100644 tests/tools/cloud/aliyun/test_openmeta_retry.py diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 79847ea92..3cc28dac8 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -7684,11 +7684,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Die Alibaba-Cloud-API-Metadaten für {operation} sind vorübergehend nicht " -"verfügbar. Versuchen Sie es später erneut." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Die Alibaba-Cloud-API-Metadaten für {operation} sind vorübergehend nicht verfügbar. Wiederhole denselben Tool-Aufruf nach kurzer Wartezeit einmal. Falls er erneut fehlschlägt, melde die vorübergehende Störung, statt die API als nicht unterstützt einzustufen." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 023139453..78739c42f 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -7630,11 +7630,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Los metadatos de la API de Alibaba Cloud para {operation} no están " -"disponibles temporalmente. Vuelva a intentarlo más tarde." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Los metadatos de la API de Alibaba Cloud para {operation} no están disponibles temporalmente. Espera un momento y vuelve a realizar la misma llamada a la herramienta una vez; si vuelve a fallar, informa del fallo temporal en lugar de considerar que la API no es compatible." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 128abf20e..d6c9a3565 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -7649,11 +7649,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Les métadonnées de l'API Alibaba Cloud pour {operation} sont " -"temporairement indisponibles. Réessayez plus tard." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Les métadonnées de l’API Alibaba Cloud pour {operation} sont temporairement indisponibles. Réessayez une fois le même appel d’outil après une courte attente ; en cas de nouvel échec, signalez une panne temporaire plutôt que de considérer l’API comme non prise en charge." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 65207836b..ecbbd231a 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -7198,9 +7198,10 @@ msgstr "Alibaba Cloud API {operation} が見つかりません。プロダクト #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "{operation} の Alibaba Cloud API メタデータは一時的に利用できません。後でもう一度お試しください。" +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "{operation} の Alibaba Cloud API メタデータは一時的に利用できません。少し待ってから同じツール呼び出しを一度再試行してください。それでも失敗する場合は、API がサポートされていないと判断せず、一時的な障害として報告してください。" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 9c19de080..429f158e9 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -7569,11 +7569,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Os metadados da API do Alibaba Cloud para {operation} estão " -"temporariamente indisponíveis. Tente novamente mais tarde." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Os metadados da API do Alibaba Cloud para {operation} estão temporariamente indisponíveis. Aguarde um pouco e repita a mesma chamada à ferramenta uma vez; se falhar novamente, informe a falha temporária em vez de considerar a API incompatível." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 3f0e0b854..07a89e2ee 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -7101,9 +7101,10 @@ msgstr "未找到阿里云 API {operation}。请检查产品、版本和操作 #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "{operation} 的阿里云 API 元数据暂时不可用,请稍后重试。" +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "{operation} 的阿里云 API 元数据暂时不可用。请稍等后重试同一工具调用一次;若仍失败,请报告临时故障,不要判断为该 API 不受支持。" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/tools/cloud/aliyun/openmeta.py b/src/iac_code/tools/cloud/aliyun/openmeta.py index da61247da..f8dd0fa97 100644 --- a/src/iac_code/tools/cloud/aliyun/openmeta.py +++ b/src/iac_code/tools/cloud/aliyun/openmeta.py @@ -8,9 +8,11 @@ import json import logging import os +import random import re import sys import tempfile +import time from collections import OrderedDict from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, replace @@ -25,6 +27,14 @@ from iac_code.a2a.backup import run_sync_fenced from iac_code.tools.cloud.aliyun.api_identifiers import is_safe_api_version +from iac_code.tools.cloud.aliyun.retry_policy import ( + RetryBudget, + RetryExhausted, + RetryReason, + map_httpx_retry_reason, + map_retryable_status, + retry_delay, +) from iac_code.tools.cloud.aliyun.user_agent import build_user_agent from iac_code.utils.async_lifecycle import await_task_to_completion from iac_code.utils.file_security import ensure_private_dir @@ -73,6 +83,7 @@ _API_FRESH_TTL = timedelta(days=7) _STALE_TTL = timedelta(days=30) _NEGATIVE_TTL = timedelta(minutes=10) +_REQUEST_TIMEOUT_SECONDS = 8.0 logger = logging.getLogger(__name__) @@ -1523,46 +1534,105 @@ async def _request_json( url: str, *, max_bytes: int, + ) -> tuple[_JsonPayload | None, OpenMetaError | None, str]: + budget = RetryBudget(deadline=time.monotonic() + _REQUEST_TIMEOUT_SECONDS, random=random.random) + started = time.monotonic() + path = httpx.URL(url).path + for _ in range(budget.max_attempts): + try: + await budget.acquire() + result = await budget.run_attempt( + lambda: self._request_json_once(url, max_bytes=max_bytes), + retryable_call=True, + ) + except RetryExhausted: + logger.warning( + "OpenMeta request deadline exhausted: path=%s attempt=%s elapsed_ms=%d", + path, + budget.attempts, + (time.monotonic() - started) * 1000, + ) + break + except httpx.HTTPError as error: + status = None + retry_after = None + reason = map_httpx_retry_reason(error) + if isinstance(error, httpx.HTTPStatusError): + status = error.response.status_code + retry_after = error.response.headers.get("Retry-After") + reason = RetryReason.RETRYABLE_STATUS if status == 500 else map_retryable_status(status) + logger.warning( + "OpenMeta request failed: path=%s attempt=%s status=%s error=%s elapsed_ms=%d", + path, + budget.attempts, + status, + type(error).__name__, + (time.monotonic() - started) * 1000, + ) + if reason is None or budget.attempts >= budget.max_attempts: + break + try: + delay = retry_delay(budget, failed_attempt=budget.attempts, retry_after=retry_after, reason=reason) + except RetryExhausted: + break + await asyncio.sleep(delay) + else: + if budget.attempts > 1: + logger.debug( + "OpenMeta request completed: path=%s attempt=%s outcome=%s elapsed_ms=%d", + path, + budget.attempts, + result[1] or "success", + (time.monotonic() - started) * 1000, + ) + return result + return None, "temporarily_unavailable", url + + async def _request_json_once( + self, + url: str, + *, + max_bytes: int, ) -> tuple[_JsonPayload | None, OpenMetaError | None, str]: current = httpx.URL(url) for _ in range(3): - try: - async with self._client.stream("GET", current) as response: - if response.status_code in {301, 302, 303, 307, 308}: - location = response.headers.get("location") - if location is None: - return None, "temporarily_unavailable", str(current) - candidate = current.join(location) - if not _safe_redirect(candidate): - return None, "temporarily_unavailable", str(current) - current = candidate - continue - if response.status_code in {204, 404}: - return None, "not_found", str(current) - if response.status_code == 429 or response.status_code >= 500: + async with self._client.stream("GET", current) as response: + if response.status_code in {301, 302, 303, 307, 308}: + location = response.headers.get("location") + if location is None: return None, "temporarily_unavailable", str(current) - if response.status_code != 200: - return None, "protocol_error", str(current) - content_length = response.headers.get("content-length") - if content_length is not None: - try: - if int(content_length) > max_bytes: - return None, "protocol_error", str(current) - except ValueError: - pass - chunks: list[bytes] = [] - received = 0 - async for chunk in response.aiter_bytes(): - received += len(chunk) - if received > max_bytes: + candidate = current.join(location) + if not _safe_redirect(candidate): + return None, "temporarily_unavailable", str(current) + current = candidate + continue + if response.status_code in {204, 404}: + return None, "not_found", str(current) + if response.status_code == 500 or map_retryable_status(response.status_code) is not None: + response.raise_for_status() + if response.status_code == 429 or response.status_code >= 500: + return None, "temporarily_unavailable", str(current) + if response.status_code != 200: + return None, "protocol_error", str(current) + content_length = response.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > max_bytes: return None, "protocol_error", str(current) - chunks.append(chunk) - except httpx.HTTPError: - return None, "temporarily_unavailable", str(current) + except ValueError: + pass + chunks: list[bytes] = [] + received = 0 + async for chunk in response.aiter_bytes(): + received += len(chunk) + if received > max_bytes: + return None, "protocol_error", str(current) + chunks.append(chunk) try: async with self._decode_semaphore: payload = await run_sync_fenced(json.loads, b"".join(chunks)) except (json.JSONDecodeError, UnicodeDecodeError): + logger.warning("OpenMeta response is not valid JSON: path=%s", current.path) return None, "temporarily_unavailable", str(current) if not isinstance(payload, Mapping | list): return None, "protocol_error", str(current) diff --git a/src/iac_code/tools/cloud/aliyun/public_errors.py b/src/iac_code/tools/cloud/aliyun/public_errors.py index f896cded1..347e565df 100644 --- a/src/iac_code/tools/cloud/aliyun/public_errors.py +++ b/src/iac_code/tools/cloud/aliyun/public_errors.py @@ -140,9 +140,11 @@ def public_aliyun_error( operation=operation ) if code == "metadata_unavailable": - return _("Alibaba Cloud API metadata for {operation} is temporarily unavailable; try again later.").format( - operation=operation - ) + return _( + "Alibaba Cloud API metadata for {operation} is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported." + ).format(operation=operation) if code == "metadata_protocol_error": return _( "Alibaba Cloud API metadata for {operation} returned an incompatible response. " diff --git a/tests/tools/cloud/aliyun/test_aliyun_api_doc.py b/tests/tools/cloud/aliyun/test_aliyun_api_doc.py index 3cf968d30..a34b3b1e8 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api_doc.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api_doc.py @@ -651,7 +651,9 @@ async def test_doc_and_execute_share_public_identity_validation_before_openmeta( ( "temporarily_unavailable", "metadata_unavailable", - "Alibaba Cloud API metadata for Ecs/DescribeInstances is temporarily unavailable; try again later.", + "Alibaba Cloud API metadata for Ecs/DescribeInstances is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported.", ), ( "protocol_error", @@ -932,7 +934,9 @@ async def test_unknown_parameter_style_uses_canonical_contract_reason() -> None: ( None, "temporarily_unavailable", - "Alibaba Cloud API metadata for Ecs/Missing is temporarily unavailable; try again later.", + "Alibaba Cloud API metadata for Ecs/Missing is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported.", ), ( None, diff --git a/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py b/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py index 377d362e7..9f43cb294 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py @@ -2160,7 +2160,9 @@ async def build(self, contract, tool_input): ), ( "metadata_unavailable", - "Alibaba Cloud API metadata for Ecs/DescribeInstances is temporarily unavailable; try again later.", + "Alibaba Cloud API metadata for Ecs/DescribeInstances is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported.", ), ( "invalid_or_missing_version", diff --git a/tests/tools/cloud/aliyun/test_openmeta.py b/tests/tools/cloud/aliyun/test_openmeta.py index 0717de5f8..99376fe85 100644 --- a/tests/tools/cloud/aliyun/test_openmeta.py +++ b/tests/tools/cloud/aliyun/test_openmeta.py @@ -1099,7 +1099,7 @@ async def test_api_request_identity_completes_official_payload_across_all_cache_ await disk_client.aclose() clock.advance(timedelta(days=8)) - stale_client = client(tmp_path, Remote([response(500), response(500)]), clock) + stale_client = client(tmp_path, Remote([response(500) for _ in range(6)]), clock) assert (await stale_client.get_product("Ecs")).value is not None stale = await stale_client.get_api("Ecs", "2014-05-26", "DescribeInstances") @@ -1249,7 +1249,7 @@ async def test_client_product_style_fallback_expires_with_products_cache_envelop [ response(200, load_fixture("products.json")), response(200, first_api), - refresh_response, + *([response(500) for _ in range(3)] if refresh_outcome == "unavailable" else [refresh_response]), response(200, second_api), response(404), ] @@ -1268,7 +1268,7 @@ async def test_client_product_style_fallback_expires_with_products_cache_envelop assert refreshed.error == "temporarily_unavailable" assert after_expiry.value is None assert after_expiry.error == "protocol_error" - assert len(remote.requests) == 5 + assert len(remote.requests) == (7 if refresh_outcome == "unavailable" else 5) assert remote.requests[-1].url.path.endswith("/Ecs/versions/2014-05-26/api-docs.json") await openmeta.aclose() @@ -1448,7 +1448,7 @@ async def test_stale_prefetched_api_docs_are_a_positive_fallback_for_exact_metad "components": describe_regions.pop("components"), "apis": {"DescribeRegions": describe_regions}, } - remote = Remote([response(200, api_docs_payload), response(503)]) + remote = Remote([response(200, api_docs_payload), *[response(503) for _ in range(3)]]) openmeta = client(tmp_path, remote, clock) openmeta.prefetch_api_docs("Ecs", ("2014-05-26",)) @@ -1459,7 +1459,7 @@ async def test_stale_prefetched_api_docs_are_a_positive_fallback_for_exact_metad assert result.value is not None and result.value.action == "DescribeRegions" assert result.source == "stale_cache" assert result.cache_status == "disk_stale" - assert len(remote.requests) == 2 + assert len(remote.requests) == 4 await openmeta.aclose() @@ -1614,7 +1614,7 @@ async def test_client_classifies_semantic_and_http_errors( tmp_path: Path, status: int, payload: object, error: str ) -> None: clock = FakeClock() - remote = Remote([response(status, payload)]) + remote = Remote([response(status, payload) for _ in range(3 if status in {429, 500} else 1)]) openmeta = client(tmp_path, remote, clock) result = await openmeta.get_api("Ecs", "2014-05-26", "DescribeInstances") @@ -1743,7 +1743,7 @@ async def test_client_rejects_a_third_safe_redirect(tmp_path: Path) -> None: @pytest.mark.asyncio async def test_client_classifies_timeout_as_temporarily_unavailable(tmp_path: Path) -> None: clock = FakeClock() - openmeta = client(tmp_path, Remote([httpx.ReadTimeout("timed out")]), clock) + openmeta = client(tmp_path, Remote([httpx.ReadTimeout("timed out") for _ in range(3)]), clock) result = await openmeta.get_api("Ecs", "2014-05-26", "DescribeInstances") @@ -1863,7 +1863,7 @@ async def test_disk_cleanup_limits_managed_cache_and_leaves_product_summary( async def test_client_fresh_stale_negative_and_corrupt_disk_cache_boundaries(tmp_path: Path) -> None: clock = FakeClock() payload = load_fixture("ecs_describe_instances.json") - remote = Remote([response(200, payload), response(500), response(404)]) + remote = Remote([response(200, payload), *[response(500) for _ in range(3)], response(404)]) openmeta = client(tmp_path, remote, clock) remote_result = await openmeta.get_api("Ecs", "2014-05-26", "DescribeInstances") @@ -1886,7 +1886,7 @@ async def test_client_fresh_stale_negative_and_corrupt_disk_cache_boundaries(tmp cache_file = tmp_path / "apis" / "Ecs" / "2014-05-26" / "DescribeInstances.json" cache_file.write_text('{"payload_sha256":"wrong"}', encoding="utf-8") - second = client(tmp_path, Remote([response(500)]), clock) + second = client(tmp_path, Remote([response(500) for _ in range(3)]), clock) assert (await second.get_api("Ecs", "2014-05-26", "DescribeInstances")).error == "temporarily_unavailable" await second.aclose() @@ -2037,7 +2037,7 @@ async def test_product_lookup_does_not_negative_cache_fresh_summary_miss_when_re } ) write_cache_envelope(tmp_path / "products.zh-cn.json", cached_products, clock) - remote = Remote([response(500, {"message": "temporary"}), response(200, refreshed_products)]) + remote = Remote([*[response(500, {"message": "temporary"}) for _ in range(3)], response(200, refreshed_products)]) openmeta = client(tmp_path, remote, clock) unavailable = await openmeta.get_product("AgentTeams") @@ -2047,7 +2047,7 @@ async def test_product_lookup_does_not_negative_cache_fresh_summary_miss_when_re assert unavailable.error == "temporarily_unavailable" assert recovered.value is not None assert recovered.value.product == "AgentTeams" - assert len(remote.requests) == 2 + assert len(remote.requests) == 4 await openmeta.aclose() diff --git a/tests/tools/cloud/aliyun/test_openmeta_retry.py b/tests/tools/cloud/aliyun/test_openmeta_retry.py new file mode 100644 index 000000000..0d0382455 --- /dev/null +++ b/tests/tools/cloud/aliyun/test_openmeta_retry.py @@ -0,0 +1,215 @@ +"""Bounded recovery of transient public metadata failures, without cloud credentials.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from pathlib import Path + +import httpx +import pytest + +from iac_code.tools.cloud.aliyun import openmeta as module +from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi +from iac_code.tools.cloud.aliyun.contract_store import canonical_input_sha256 +from iac_code.tools.cloud.aliyun.openmeta import OpenMetaClient +from iac_code.tools.cloud.aliyun.retry_policy import retry_delay +from iac_code.tools.cloud.aliyun.runtime import create_aliyun_runtime_services +from iac_code.types.permissions import InvocationBinding, ToolPermissionContext + + +@pytest.fixture(autouse=True) +def immediate_backoff(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "retry_delay", lambda *_args, **_kwargs: 0.0) + + +def api_response() -> httpx.Response: + fixture = Path(__file__).parent / "fixtures/openmeta/ecs_describe_instances.json" + return httpx.Response(200, json=json.loads(fixture.read_text(encoding="utf-8"))) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + [429, 500, 502, 503, 504, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ConnectError, httpx.ReadError], +) +async def test_transient_metadata_failure_recovers_and_is_cached(tmp_path: Path, failure) -> None: + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + if isinstance(failure, int): + return httpx.Response(failure) + raise failure("temporary failure") + return api_response() + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + try: + recovered = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + cached = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert recovered.value is not None + assert cached.cache_status == "memory_fresh" + assert calls == 2 + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_exhausted_metadata_retries_do_not_negative_cache(tmp_path: Path) -> None: + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(503) if calls <= 3 else api_response() + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + try: + failed = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert failed.error == "temporarily_unavailable" + assert calls == 3 + recovered = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert recovered.value is not None + assert calls == 4 + finally: + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [400, 401, 403, 404, 501, 505]) +async def test_permanent_metadata_failure_does_not_retry(tmp_path: Path, status: int) -> None: + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(status) + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + try: + result = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert result.value is None + assert calls == 1 + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_metadata_deadline_cancels_inflight_read(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "_REQUEST_TIMEOUT_SECONDS", 0.03) + cancelled = asyncio.Event() + + async def handler(_request: httpx.Request) -> httpx.Response: + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + started = time.monotonic() + try: + result = await asyncio.wait_for(client.get_api("Ecs", "2014-05-26", "DescribeInstances"), timeout=1) + assert result.error == "temporarily_unavailable" + assert cancelled.is_set() + assert time.monotonic() - started < 0.5 + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_backoff_cannot_cross_metadata_deadline(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "_REQUEST_TIMEOUT_SECONDS", 0.01) + monkeypatch.setattr(module, "retry_delay", retry_delay) + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(503, headers={"Retry-After": "2"}) + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + started = time.monotonic() + try: + result = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert result.error == "temporarily_unavailable" + assert calls == 1 + assert time.monotonic() - started < 0.5 + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_concurrent_waiters_share_metadata_retries(tmp_path: Path) -> None: + calls = 0 + started = asyncio.Event() + release = asyncio.Event() + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + started.set() + await release.wait() + return httpx.Response(503) if calls < 3 else api_response() + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + tasks = [asyncio.create_task(client.get_api("Ecs", "2014-05-26", "DescribeInstances")) for _ in range(10)] + try: + await started.wait() + await asyncio.sleep(0) + release.set() + results = await asyncio.gather(*tasks) + assert all(result.value is not None for result in results) + assert calls == 3 + finally: + await client.aclose() + await asyncio.gather(*tasks, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_retry_diagnostics_exclude_query_and_exception_payload(tmp_path: Path, caplog) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("PRIVATE") + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + try: + with caplog.at_level(logging.WARNING, logger=module.__name__): + result = await client._request_json("https://api.aliyun.com/meta/v1/demo?token=PRIVATE", max_bytes=1024) + assert result[1] == "temporarily_unavailable" + assert "ConnectError" in caplog.text + assert "attempt=3" in caplog.text + assert "elapsed_ms=" in caplog.text + assert "/meta/v1/demo" in caplog.text + assert "PRIVATE" not in caplog.text + assert "token=" not in caplog.text + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_final_metadata_failure_tells_model_to_retry_without_using_credentials(tmp_path: Path) -> None: + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(503) + + runtime = create_aliyun_runtime_services(cache_dir=tmp_path, openmeta_transport=httpx.MockTransport(handler)) + runtime.credential_provider = lambda: pytest.fail("metadata failure must precede cloud credential access") + tool_input = {"product": "SWAS-OPEN", "action": "ListPlans", "region_id": "cn-hangzhou"} + binding = InvocationBinding("runtime", "session", "call", "aliyun_api", canonical_input_sha256(tool_input)) + try: + permission = await AliyunApi(services=runtime).check_permissions( + tool_input, + ToolPermissionContext(cwd=str(tmp_path), invocation_binding=binding, pipeline_mode=True), + ) + assert permission.behavior == "deny" + assert permission.message is not None + assert "Retry the same tool call once" in permission.message + assert calls == 3 + finally: + await runtime.aclose() diff --git a/tests/tools/cloud/aliyun/test_public_errors.py b/tests/tools/cloud/aliyun/test_public_errors.py index 68aecdab3..0aea9ef08 100644 --- a/tests/tools/cloud/aliyun/test_public_errors.py +++ b/tests/tools/cloud/aliyun/test_public_errors.py @@ -57,7 +57,9 @@ def test_aliyun_public_error_templates_are_directly_extractable(tmp_path: Path) "Verify the cloud resource state before retrying.", "Alibaba Cloud returned an unsupported response body for {operation}. " "Verify the cloud resource state before retrying.", - "Alibaba Cloud API metadata for {operation} is temporarily unavailable; try again later.", + "Alibaba Cloud API metadata for {operation} is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported.", "Alibaba Cloud endpoint cache could not be updated for {operation} in {region}. " "Check local configuration storage and retry.", "Alibaba Cloud API parameters are invalid for {operation}.", From 9b40f4d33ec9a756af2696e85fcd0ca6933d5280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 22 Oct 2026 11:03:54 +0800 Subject: [PATCH 05/16] fix(pipeline): require product guides before solution planning --- .../skills/iac-aliyun-solution-first/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md index 5683ca70a..066055134 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md @@ -161,7 +161,9 @@ user_invocable: false ## 第二部分:详细架构规划 -查阅 [references/cloud-products/](references/cloud-products/) 下对应产品文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md),了解选型策略、配置限制和计费方式;按需读取本次涉及的产品资料。 +规划、粗估或调用某个阿里云产品的 API 前,必须先用 `read_file` 读取 [references/cloud-products/](references/cloud-products/) 下该产品的对应文档,了解选型策略、配置限制和计费方式。同一上下文已完整读取时直接复用;只加载本次涉及产品的文档。 + +产品与文件映射:云服务器 ECS → `ecs.md`;轻量应用服务器 SWAS → `swas.md`;云数据库 RDS → `rds.md`;云数据库 Redis → `redis.md`;负载均衡 SLB/CLB/ALB/NLB → `slb.md`;专有网络 VPC → `vpc.md`;对象存储 OSS → `oss.md`;全球加速 GA → `ga.md`。 ### 核心原则:按需设计,不过度发挥 From c1ec29b760b3260dce17370f1baad09d7cdc3b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 22 Oct 2026 15:41:04 +0800 Subject: [PATCH 06/16] fix(pipeline): handle promotional offer followups during solution selection Preserve minimum specifications when users also provide recommendations. Answer promotional offer followups through the opt-in regional ROS assistant handoff without treating questions as selection confirmation. Clarify economy e and burstable t instance characteristics in the ECS guide. Add behavior-named real HTTP/SSE coverage and offline checks for visible links and accepted pipeline conclusions. --- scripts/ci/README.md | 10 +- scripts/ci/run_e2e.py | 8 +- .../ci/run_promotional_offer_followup_a2a.py | 272 ++++++++++++++++++ .../skills/iac-aliyun-solution-first/SKILL.md | 5 +- .../ros-agent-awareness.md | 2 +- .../references/cloud-products/ecs.md | 4 + .../test_ros_agent_awareness.py | 4 +- tests/scripts/test_ci_run_e2e.py | 9 +- .../test_promotional_offer_followup_a2a.py | 34 +++ 9 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 scripts/ci/run_promotional_offer_followup_a2a.py create mode 100644 tests/scripts/test_promotional_offer_followup_a2a.py diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 23664b8e9..232ca5ff9 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -35,7 +35,7 @@ uv run --no-sync python scripts/ci/run_e2e.py --suite live --jobs 4 \ --allow-cloud-write ``` -默认三文件模式下,凭证目录须含 `.credentials.yml`、`.cloud-credentials.yml`、`settings.yml`。建议使用专用测试账号、受限权限和资源配额。`live` 共 129 个:42 个 selling flow A2A/REPL 场景、20 个 A2A 只读资源选择(含 12 个重启恢复矩阵)、6 个 AGUI HTTP/SSE 资源选择、16 个 REPL 旧场景、33 个 A2A 恢复场景、3 个 VPC 模板 smoke、1 个只读云 API canary、8 个 ROS 助手引导真实模型场景。其中包含真实 ROS 资源创建用例。需要缩小范围时可选 `live-core`、`live-recovery`、`live-multimodal`、`live-readonly`、`live-legacy`、`live-safety`、`live-repl`、`live-smoke` 或 `live-agui`,也可用 `--case` 指定单个用例。AGUI 的 6 条覆盖普通聊天与 Pipeline 的选择、取消和直接输入,通过 HTTP/SSE 运行,不需要浏览器;使用文本模型池,查询已有 VPC,不创建资源,清理状态为“无需清理”。可单独运行 `--suite live-agui`,或指定 `--case agui-selector-normal-selected`。三个 VPC smoke 只接收 LLM 与 settings 配置,使用隔离的 HOME,不接收云凭证;它们验证生成模板,不创建 VPC。并行 selling 场景各用独立的 `10.250.0.0/16` 子网池,避免独立进程重复预留同一 VSwitch CIDR;原 runner 单独运行仍沿用原池。旧 A2A、REPL 和 selling 场景不强制测试 StackName;清理读取隔离会话中真实 CreateStack 接受回执,按实际 ID、名称和地域校验后删除。仅查询、等待或模型提及的资源不能授权删除;硬超时后由独立清理进程再次尝试。selling、REPL 旧用例和 A2A 恢复用例的硬超时为 45 分钟,之后最多留 15 分钟清理,再强制结束进程组。硬超时不代表清理成功,需检查报告和测试账号残留资源。 +默认三文件模式下,凭证目录须含 `.credentials.yml`、`.cloud-credentials.yml`、`settings.yml`。建议使用专用测试账号、受限权限和资源配额。`live` 共 130 个:42 个 selling flow A2A/REPL 场景、20 个 A2A 只读资源选择(含 12 个重启恢复矩阵)、6 个 AGUI HTTP/SSE 资源选择、16 个 REPL 旧场景、33 个 A2A 恢复场景、3 个 VPC 模板 smoke、1 个只读云 API canary、8 个 ROS 助手引导真实模型场景、1 个活动商品追问 A2A 场景。其中包含真实 ROS 资源创建用例。需要缩小范围时可选 `live-core`、`live-recovery`、`live-multimodal`、`live-readonly`、`live-legacy`、`live-safety`、`live-repl`、`live-smoke` 或 `live-agui`,也可用 `--case` 指定单个用例。AGUI 的 6 条覆盖普通聊天与 Pipeline 的选择、取消和直接输入,通过 HTTP/SSE 运行,不需要浏览器;使用文本模型池,查询已有 VPC,不创建资源,清理状态为“无需清理”。可单独运行 `--suite live-agui`,或指定 `--case agui-selector-normal-selected`。三个 VPC smoke 只接收 LLM 与 settings 配置,使用隔离的 HOME,不接收云凭证;它们验证生成模板,不创建 VPC。并行 selling 场景各用独立的 `10.250.0.0/16` 子网池,避免独立进程重复预留同一 VSwitch CIDR;原 runner 单独运行仍沿用原池。旧 A2A、REPL 和 selling 场景不强制测试 StackName;清理读取隔离会话中真实 CreateStack 接受回执,按实际 ID、名称和地域校验后删除。仅查询、等待或模型提及的资源不能授权删除;硬超时后由独立清理进程再次尝试。selling、REPL 旧用例和 A2A 恢复用例的硬超时为 45 分钟,之后最多留 15 分钟清理,再强制结束进程组。硬超时不代表清理成功,需检查报告和测试账号残留资源。 CI 可改用 `--cloud-credential-helper /path/to/helper.py`,此时源目录只需 LLM 凭证和 `settings.yml`。Helper 接口为 `cloud --output <目标 .cloud-credentials.yml 路径>`;它在每个真实云用例开始前调用,长用例每 10 分钟调用一次,异常清理前也会再次调用。若 helper 需要独立 Python 环境,传 `--cloud-credential-python /path/to/python`。Helper 必须原子地写入私有权限文件,且不得在标准输出或错误输出打印凭证。模板 smoke 不调用 helper。本地原有的三文件运行方式继续支持。 @@ -72,6 +72,14 @@ uv run --no-sync python scripts/ci/run_e2e.py \ --credential-source-dir /path/to/test-config --jobs 2 --retries 0 ``` +`promotional-offer-followup-a2a` 通过生产 HTTP/SSE A2A Server 验证真实模型:先为“最低 2G、推荐 4G”的旅游 Agent 规划服务器,再保留会话追问活动商品,最后明确咨询活动购买资格。检查内存下限、部署上下文中的同地域助手引导,以及独立活动咨询的可见链接和持久化结束原因。测试不替换模型或工具结果、不选择部署方案,仅复制 LLM 凭证与 settings,不接收云凭证、不查询实际活动资格;首轮历史附图中的规格以文本复现。四模型覆盖需分别指定每个 `--text-model` 运行,不把单次调度当成四模型全覆盖。 + +```bash +uv run --no-sync python scripts/ci/run_e2e.py --case promotional-offer-followup-a2a \ + --text-model deepseek-v4.1-flash --credential-source-dir /path/to/test-config \ + --jobs 1 --retries 0 +``` + ## 真实用例的模型池与思考强度 真实套件默认采用滚动调度:文本用例使用 `deepseek-v4-flash-0731`、`glm-5.2-fast-preview`、`glm-5.3-prime`、`deepseek-v4.1-flash`,每模型最多两个用例;多模态用例使用 `qwen3.8-max`、`qwen3.8-max-0902`、`qwen3.8-flash`、`qwen3.8-omni-flash`,每模型最多一个用例。两类模型池不能互相借用,总共最多 12 个用例。仅有文本用例待运行时最多八个;仅有多模态用例时最多四个。 diff --git a/scripts/ci/run_e2e.py b/scripts/ci/run_e2e.py index 82387d89d..e8319f915 100644 --- a/scripts/ci/run_e2e.py +++ b/scripts/ci/run_e2e.py @@ -26,7 +26,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] CLOUD_REFRESH_SECONDS = 600 -LLM_ONLY_RUNNERS = frozenset({"smoke", "ros_awareness"}) +LLM_ONLY_RUNNERS = frozenset({"smoke", "ros_awareness", "promotional_offer_followup"}) SAFE_LIVE_AUDIT_NOTE = re.compile( r"credential audit: source=(?:llm|cloud); " r"location=(?:logs|artifacts|workspace|templates|other); " @@ -304,6 +304,10 @@ def _selling_group(spec: Any) -> str: ) for scenario in ROS_AWARENESS_SCENARIOS ) + ( + Case( + "promotional-offer-followup-a2a", "scripts/ci/run_promotional_offer_followup_a2a.py", (), 1200, "live", + live_runner="promotional_offer_followup", group="readonly", + ), Case("smoke-a2a-vpc", "scripts/a2a/smoke/test_a2a_vpc.py", (), 800, "live", live_runner="smoke", group="smoke"), Case("smoke-acp-vpc", "scripts/acp/smoke/test_acp_vpc.py", (), 450, "live", live_runner="smoke", group="smoke"), Case( @@ -1512,7 +1516,7 @@ def run_case( command.extend(("--provider", "dashscope")) if case.live_runner not in LLM_ONLY_RUNNERS and cloud_credential_helper is not None: _prepare_cloud_credentials(cloud_credential_helper, source_config_dir, cloud_credential_python) - if case.live_runner == "ros_awareness": + if case.live_runner in {"ros_awareness", "promotional_offer_followup"}: command.append("--allow-real-llm") elif case.live_runner not in LLM_ONLY_RUNNERS: command.append("--allow-real-cloud") diff --git a/scripts/ci/run_promotional_offer_followup_a2a.py b/scripts/ci/run_promotional_offer_followup_a2a.py new file mode 100644 index 000000000..cdbcff66b --- /dev/null +++ b/scripts/ci/run_promotional_offer_followup_a2a.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Verify minimum specs and promotional offer followups over real HTTP/SSE A2A. + +Run via run_e2e.py for private LLM-only configuration. No scripted model or +tool results are injected. Cloud credentials are not supplied to this case. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any +from urllib.request import Request, urlopen + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.debugger import ( # noqa: E402 + A2A_VERSION_HEADERS, + _extract_pipeline_envelopes, + build_task_cancel_payload, +) +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + fetch_pipeline_state, + stream_message, + wait_for_server, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +PROMPTS = ( + "我自己在做一个 agent 小项目,是旅游规划。建议腾讯云或者阿里云," + "这次用阿里云杭州,一台服务器,给我推荐一个方案。" + "算力要求:2核4G(最低2核2G,推荐4G内存以避免运行时内存不足)。" + "系统镜像要选 CentOS,防火墙开放端口:10010,9090,3000,59000,54328,需要公网访问。", + "为什么不能选那个99元的?ecs.e-c1m1.large 热门推荐,经济型 e,2 vCPU,2 GiB," + "76个可用区,X86计算-共享型,¥45.14/月,惠¥19.86/月。", + "我现在只想确认这个99元活动我的账号能不能买、优惠包含什么、从哪里买,暂时不继续部署。", +) +DESTINATION = "https://ros.console.aliyun.com/cn-hangzhou/chat" + + +def walk(value: Any): + if isinstance(value, dict): + yield value + for item in value.values(): + yield from walk(item) + elif isinstance(value, list): + for item in value: + yield from walk(item) + + +def completion_inputs(config_dir: Path) -> list[dict[str, Any]]: + found: dict[str, dict[str, Any]] = {} + paths = (config_dir / "projects").rglob("session.jsonl") + for path in sorted(paths, key=lambda item: item.stat().st_mtime_ns): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") == "tool_use" and node.get("name") == "complete_step": + value = node.get("input", {}).get("conclusion") + if isinstance(value, dict): + found[str(node.get("id", len(found)))] = value + return list(found.values()) + + +def memory_constraints(value: Any) -> list[dict[str, Any]]: + return [ + item + for node in walk(value) + for item in node.get("hard_constraints", []) + if isinstance(item, dict) and str(item.get("property", "")).casefold() in {"memory", "memory_gib"} + ] + + +def visible_pipeline_text(path: Path) -> str: + """Use only user-visible pipeline deltas, excluding tool arguments and thinking.""" + text = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for envelope in _extract_pipeline_envelopes(json.loads(line)): + if envelope.get("eventType") == "text_delta": + value = envelope.get("data", {}).get("text") + if isinstance(value, str): + text.append(value) + return "".join(text) + + +def pipeline_facts(path: Path) -> tuple[dict[str, Any], list[str]]: + conclusion: dict[str, Any] = {} + steps = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for envelope in _extract_pipeline_envelopes(json.loads(line)): + data = envelope.get("data", {}) + value = data.get("normalizedConclusion") or data.get("conclusion") + if isinstance(value, dict) and value.get("status"): + conclusion = value + step = envelope.get("step", {}) + if envelope.get("eventType") == "step_started" and isinstance(step, dict) and step.get("id"): + steps.append(step["id"]) + return conclusion, steps + + +def run(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run via run_e2e.py with isolated configuration") + if (config_dir / ".cloud-credentials.yml").exists(): + raise ValueError("This case requires LLM-only configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), + "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", + "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", + "IAC_CODE_PROVIDER": args.provider or "dashscope", + "IAC_CODE_MODEL": args.model, + "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + ) + checks: dict[str, bool] = {} + turns = [] + context_id = task_id = "" + try: + server.start() + wait_for_server(f"http://127.0.0.1:{port}", timeout=60, owned_server=server) + for index, prompt in enumerate(PROMPTS): + started = time.monotonic() + canceled_unselected_implementation = False + + def stop_unselected_implementation(event: Any) -> None: + nonlocal canceled_unselected_implementation + if index != 1 or canceled_unselected_implementation: + return + for envelope in _extract_pipeline_envelopes(event): + if ( + envelope.get("eventType") == "step_started" + and envelope.get("step", {}).get("id") == "materialize_selected_candidate" + ): + request = Request( + f"http://127.0.0.1:{port}/", + data=json.dumps(build_task_cancel_payload(task_id=task_id)).encode("utf-8"), + headers={"Content-Type": "application/json", **A2A_VERSION_HEADERS}, + ) + with urlopen(request, timeout=30) as response: + canceled_unselected_implementation = not bool(json.load(response).get("error")) + + summary = stream_message( + server_url=f"http://127.0.0.1:{port}", cwd=str(workspace), prompt=prompt, + context_id=context_id, task_id=task_id, name=f"turn-{index + 1}", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + on_event=stop_unselected_implementation, + ) + context_id, task_id = summary.context_id, summary.task_id + state = fetch_pipeline_state( + server_url=f"http://127.0.0.1:{port}", context_id=context_id, task_id=task_id, + run_dir=args.run_dir, name=f"turn-{index + 1}", redaction_env=env, + ) + completions = completion_inputs(config_dir) + latest, steps = pipeline_facts(args.run_dir / f"turn-{index + 1}.events.jsonl") + latest = latest or (completions[-1] if completions else {}) + constraints = memory_constraints(latest or state) + evidence = { + "turn": index + 1, "seconds": round(time.monotonic() - started, 2), + "http_sse": summary.response_content_type == "text/event-stream", + "task_state": summary.last_status_state, + "pipeline_handoff": summary.normal_handoff_ready, + "step": summary.last_input_required_step_id, + "assistant_link_visible": DESTINATION in visible_pipeline_text( + args.run_dir / f"turn-{index + 1}.events.jsonl" + ), + "completion_status": latest.get("status"), + "memory_constraints": constraints, + "started_steps": steps, + "unselected_implementation_canceled": canceled_unselected_implementation, + } + turns.append(evidence) + checks[f"turn {index + 1}: production HTTP/SSE"] = evidence["http_sse"] + checks[f"turn {index + 1}: no failed task"] = summary.last_status_state not in { + "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "", + } + if index == 0: + checks["minimum 2 GiB remains the lower bound"] = bool(constraints) and all( + item.get("operator") == "gte" and item.get("value") == 2 for item in constraints + ) + checks["initial deployment remains in Step 1"] = ( + summary.last_input_required_step_id == "solution_planning_and_selection" + and not summary.normal_handoff_ready + ) + elif index == 1: + checks["activity followup offers the same-region assistant"] = evidence["assistant_link_visible"] + checks["activity followup saves the linked handoff"] = ( + latest.get("status") == "rejected" and DESTINATION in latest.get("rejection_reason", "") + ) + checks["activity followup does not advance to implementation"] = ( + "materialize_selected_candidate" not in steps and latest.get("status") != "selected" + ) + else: + checks["explicit activity inquiry has a visible assistant link"] = evidence["assistant_link_visible"] + checks["explicit activity handoff saves the linked reply"] = ( + latest.get("status") == "rejected" and DESTINATION in latest.get("rejection_reason", "") + ) + print(json.dumps({"model": args.model, **evidence}, ensure_ascii=False), flush=True) + if summary.normal_handoff_ready: + context_id = task_id = "" + finally: + server.terminate() + records = observe.stop() + checks["server stopped"] = server.process is not None and server.process.poll() is not None + for name in (".credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + checks["credential copies removed"] = not (config_dir / ".credentials.yml").exists() + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records + if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["only the requested real model ran"] = observed == [args.model] + return { + "scenario": "promotional-offer-followup-a2a", "model": args.model, + "status": "passed" if all(checks.values()) else "failed", + "cleanup_status": "not-needed", "checks": checks, "turns": turns, + "observed_models": observed, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", required=True) + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--allow-real-llm", action="store_true") + parser.add_argument("--timeout", type=float, default=300) + args = parser.parse_args() + if not args.allow_real_llm: + parser.error("--allow-real-llm is required") + args.run_dir.mkdir(parents=True, exist_ok=True) + try: + summary = run(args) + except Exception as exc: + summary = { + "scenario": "promotional-offer-followup-a2a", "model": args.model, "status": "failed", + "cleanup_status": "not-needed", "error_type": type(exc).__name__, + } + (args.run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False), flush=True) + return 0 if summary["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md index 066055134..8b7ccd375 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md @@ -122,6 +122,8 @@ user_invocable: false ### 硬约束提取规则 +CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分用户的最低要求、推荐配置与固定要求,不得擅自升降规格或把推荐值提升为硬约束。同一规格同时有最低值和推荐值时,最低值作为约束下限,推荐值仅作选型偏好。 + 每条硬约束是一个对象,字段为 `id`、`target`、`property`、`operator`、`value`、可选 `unit`、`verification_mode`、`source`、`source_text`: - `id`:当前请求内稳定且唯一的约束标识;用户修改同一约束时保持 ID,内容更新为最新要求。 @@ -136,7 +138,7 @@ user_invocable: false 提取规则: -- "2 核 4 GiB"可提取为同一目标的 `vcpu eq 2 count` 与 `memory eq 4 GiB` 两条约束;它们需要把实际产品规格映射到具体部署参数,因此使用 `verification_mode: tool`。这里只负责忠实表达,不选择具体实例规格或 API。 +- 用户明确固定为"2 核 4 GiB"时,提取 `vcpu eq 2 count` 与 `memory eq 4 GiB`;若同时说明"最低 2 GiB,推荐 4 GiB",内存只提取 `memory gte 2 GiB`,不得再加 `memory eq/gte 4 GiB`。规格需要映射到具体部署参数,使用 `verification_mode: tool`;这里只忠实表达,不选择具体实例规格或 API。 - 将用户口语单位规范化后写入结构化字段:CPU 的"核/核心/vCPU"统一为 `count`,内存语境中的 `g/G` 统一为 `GiB`、`m/M` 统一为 `MiB`;保留用户原始表达在 `source_text`,不要把内存单位误解为带宽单位。 - "至少 100 GiB""带宽不超过 20 Mbps""只能用 8.0""不要公网 IP"分别使用 `gte`、`lte`、`in/eq`、`eq false` 等通用表达。 - 实际值能从模板属性或最终部署参数直接定位时使用 `verification_mode: direct`;依赖云产品元数据、SKU 映射、库存或已有资源状态时使用 `verification_mode: tool`。 @@ -388,6 +390,7 @@ user_invocable: false 用户选择后本步骤会带着已保存的候选上下文恢复执行: +- "为什么不能选……""这个能用吗"等适用性或价格追问不是选案确认。先回答问题;没有明确选择或修改要求时,不提交 `selected`、不进入实现,也不重新规划。 - 结构化选择消息优先使用候选坐标 `selected_candidate_index` / `selected_evaluated_candidate_index`(两者一致,都是 0 基下标)。 - 用户提供候选名称时按 `name` 匹配;名称重复时必须用下标消歧。 - 本步骤只确定架构方案,不接收部署参数覆盖。结构化消息中的 `parameter_overrides`、`deployment_parameters` 或 `parameters` 不写入结论;模板生成后的部署参数统一由下一步处理。 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md index 7c7caf3ae..d03635dd1 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md @@ -1 +1 @@ -ROS console override: For standalone Alibaba Cloud billing, promotions/renewals, availability/pricing or traffic-offset queries, send a text reply with {ai_assistant_destination} first (exact URL; tool arguments alone are not a reply). Then call `complete_step` with `status: rejected` and that linked reply as `rejection_reason`. This overrides deployment clarification/reselection. Keep deployment-related questions here; do not promise discounts or session transfer. +ROS console override: For standalone billing, promotions/renewals, availability/pricing or traffic-offset queries, and promotional-offer followups during selection, briefly address technical suitability, then visibly link {ai_assistant_destination}. Call `complete_step` with `status: rejected`, copying that linked reply into `rejection_reason`; override clarification/reselection. Questions are not selections. Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. diff --git a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md index c27f625c5..d511514f1 100644 --- a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +++ b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md @@ -14,6 +14,10 @@ - 性价比优先配置:u1-/u2a-/u2i- - 性能优先配置:g9i/c9i/r9i +规格族特点: +- **经济型 e**:低成本共享型实例,适合轻量应用和开发测试,CPU 性能可能因资源竞争而波动。 +- **突发性能型 t 系列**:适合低平均负载、偶发高负载,通过 CPU 积分提供突发性能,需关注基准性能及积分费用。 + ### vCPU / 内存确定原则 - 用户明确指定 vCPU 或内存时,该数值是硬约束,直接覆盖本节的场景化推荐与档位推导。必须用 `DescribeInstanceTypes` 返回的实际规格验证,不能根据实例规格名猜测,也不能静默升级或降级。 diff --git a/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py b/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py index 0f7448f1b..beb76b72c 100644 --- a/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py +++ b/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py @@ -122,7 +122,7 @@ async def prompt_for(region): assert f"https://ros.console.aliyun.com/{own}/chat" in skill_and_step assert "ROS console override" in skill_and_step assert "ros_agent_awareness" not in prompt - assert "This overrides deployment clarification/reselection" in prompt + assert "override clarification/reselection" in prompt assert "with `status: rejected`" in prompt @@ -134,7 +134,7 @@ def test_handoff_is_limited_to_planning_and_leaves_normal_prompts_alone(executor assert "ROS console override" in prompts[0] assert all("ROS console override" not in prompt for prompt in prompts[1:]) assert "ROS console override" not in build_system_prompt(cwd=str(tmp_path)) - assert "Keep deployment-related questions here" in prompts[0] + assert "Keep deployment architecture/cost tradeoffs here" in prompts[0] assert "do not promise discounts or session transfer" in prompts[0] diff --git a/tests/scripts/test_ci_run_e2e.py b/tests/scripts/test_ci_run_e2e.py index 5cdc9afaf..dbd8d3c01 100644 --- a/tests/scripts/test_ci_run_e2e.py +++ b/tests/scripts/test_ci_run_e2e.py @@ -167,8 +167,8 @@ def test_catalog_includes_headless_surfaces_and_excludes_browser_desktop() -> No full = run_e2e.select_cases(run_e2e.parse_args(["--suite", "full", "--list"])) live = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live", "--list"])) assert len(full) == 66 - assert len(live) == 129 - assert len(run_e2e.CASES) == 195 + assert len(live) == 130 + assert len(run_e2e.CASES) == 196 agui = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live-agui", "--list"])) assert {case.name for case in agui} == {"agui-selector-" + name for name in run_e2e.AGUI_SCENARIOS} assert len(agui) == 6 @@ -978,7 +978,7 @@ def test_live_adapter_uses_isolated_credentials_and_sanitized_result( assert len(scenarios[0].name.removeprefix("scenario-")) == 32 -@pytest.mark.parametrize("runner", ["smoke", "ros_awareness"]) +@pytest.mark.parametrize("runner", ["smoke", "ros_awareness", "promotional_offer_followup"]) def test_llm_only_adapter_has_model_config_but_no_cloud_credentials( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, runner: str ) -> None: @@ -994,7 +994,8 @@ def test_llm_only_adapter_has_model_config_but_no_cloud_credentials( "assert Path(os.environ['HOME']).is_relative_to(run_dir)\n" "assert Path(os.environ['USERPROFILE']).is_relative_to(run_dir)\n" "assert '--allow-real-cloud' not in sys.argv\n" - "assert ('--allow-real-llm' in sys.argv) == ('ros_awareness' == sys.argv[sys.argv.index('--runner') + 1])\n" + "assert ('--allow-real-llm' in sys.argv) == " + "(sys.argv[sys.argv.index('--runner') + 1] in {'ros_awareness', 'promotional_offer_followup'})\n" "(run_dir / 'summary.json').write_text(" "json.dumps({'passed': True, 'checks': {'ok': True}}), encoding='utf-8')\n", encoding="utf-8", diff --git a/tests/scripts/test_promotional_offer_followup_a2a.py b/tests/scripts/test_promotional_offer_followup_a2a.py new file mode 100644 index 000000000..476bee4cb --- /dev/null +++ b/tests/scripts/test_promotional_offer_followup_a2a.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json + +from scripts.ci.run_promotional_offer_followup_a2a import DESTINATION, pipeline_facts, visible_pipeline_text + + +def test_visible_link_reassembles_wire_deltas_without_accepting_tool_or_thinking(tmp_path): + path = tmp_path / "turn.events.jsonl" + events = [ + {"eventType": "thinking_delta", "data": {"text": DESTINATION}}, + {"eventType": "tool_result", "data": {"input": {"rejection_reason": DESTINATION}}}, + {"eventType": "text_delta", "data": {"text": "[AI Assistant](https://ros"}}, + {"eventType": "text_delta", "data": {"text": ".console.aliyun.com/cn-hangzhou/chat)"}}, + ] + wire = [ + {"result": {"statusUpdate": {"metadata": {"iac_code": {"pipeline": event}}}}} + for event in events + ] + path.write_text("\n".join(json.dumps(event) for event in wire), encoding="utf-8") + assert visible_pipeline_text(path) == f"[AI Assistant]({DESTINATION})" + + +def test_pipeline_facts_use_accepted_conclusion_and_record_unselected_step_advance(tmp_path): + path = tmp_path / "turn.events.jsonl" + events = [ + {"eventType": "tool_use", "data": {"input": {"conclusion": {"status": "rejected"}}}}, + {"eventType": "tool_result", "data": {"normalizedConclusion": {"status": "selected"}}}, + {"eventType": "step_started", "step": {"id": "materialize_selected_candidate"}}, + ] + path.write_text("\n".join(json.dumps(event) for event in events), encoding="utf-8") + conclusion, steps = pipeline_facts(path) + assert conclusion == {"status": "selected"} + assert steps == ["materialize_selected_candidate"] From 61b31c8c8618e0566535b6e08c62a890483125c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 22 Oct 2026 19:14:12 +0800 Subject: [PATCH 07/16] fix(pipeline): support time-based estimates and coupon budgets Honor explicit estimate units and durations with period-neutral planning fields. Normalize legacy records and candidates in Python while preserving UI event contracts and valid selection recovery. Load coupon guidance on demand, query current-account eligibility, and retain deployment planning for coupon followups. Add read-only real A2A coverage and a real-model coupon fixture matrix under scripts/ci. Use period-neutral estimate labels with updated browser cache versions, localized errors, and tests for historical candidate recovery. --- scripts/ci/README.md | 26 +- scripts/ci/coupon_fixture_server.py | 56 ++++ scripts/ci/coupon_fixtures.py | 86 +++++ scripts/ci/run_coupon_budget_a2a.py | 306 ++++++++++++++++++ scripts/ci/run_coupon_budget_mock_a2a.py | 258 +++++++++++++++ scripts/ci/run_e2e.py | 13 +- .../i18n/locales/de/LC_MESSAGES/messages.po | 4 +- .../i18n/locales/de/LC_MESSAGES/webui.po | 4 + .../i18n/locales/es/LC_MESSAGES/messages.po | 4 +- .../i18n/locales/es/LC_MESSAGES/webui.po | 4 + .../i18n/locales/fr/LC_MESSAGES/messages.po | 4 +- .../i18n/locales/fr/LC_MESSAGES/webui.po | 4 + .../i18n/locales/ja/LC_MESSAGES/messages.po | 4 +- .../i18n/locales/ja/LC_MESSAGES/webui.po | 4 + .../i18n/locales/pt/LC_MESSAGES/messages.po | 4 +- .../i18n/locales/pt/LC_MESSAGES/webui.po | 4 + .../i18n/locales/zh/LC_MESSAGES/messages.po | 4 +- .../i18n/locales/zh/LC_MESSAGES/webui.po | 4 + .../pipeline/selling/references/coupons.md | 72 +++++ .../selling_solution_first/hooks/deploying.py | 4 + .../hooks/materialize_selected_candidate.py | 4 + .../hooks/solution_planning_and_selection.py | 29 +- .../selling_solution_first/pipeline.yaml | 18 +- .../skills/iac-aliyun-solution-first/SKILL.md | 17 +- .../iac-aliyun-solution-first/evals.json | 6 +- .../ros-agent-awareness.md | 2 +- .../tools/candidate_planning_records.py | 55 +++- .../tools/show_architecture_plan_tool.py | 15 +- .../tools/show_candidate_detail_tool.py | 17 +- src/iac_code/web/static/diagram-preview.html | 2 +- src/iac_code/web/static/index.html | 2 +- src/iac_code/web/static/js/app.js | 6 +- .../web/static/js/architecture_diagram.js | 2 +- .../web/static/js/components/output_panel.js | 4 +- .../web/static/js/components/pipeline.js | 4 +- src/iac_code/web/static/js/diagram_preview.js | 2 +- src/iac_code/web/static/js/mermaid_render.js | 2 +- tests/a2a/test_pipeline_snapshot.py | 4 +- .../test_candidate_batch_e2e.py | 40 ++- .../test_completion_projection.py | 37 ++- .../test_cost_field_compatibility.py | 134 ++++++++ .../selling_solution_first/test_packaging.py | 2 +- .../test_show_architecture_plan_tool.py | 50 +-- .../test_solution_planning_step.py | 8 +- tests/scripts/test_ci_run_e2e.py | 4 +- tests/scripts/test_coupon_budget_a2a.py | 16 + tests/scripts/test_coupon_fixtures.py | 66 ++++ tests/test_setup_packaging.py | 13 +- tests/web/test_diagram_preview.py | 2 +- tests/web/test_frontend_static.py | 35 +- 50 files changed, 1328 insertions(+), 139 deletions(-) create mode 100644 scripts/ci/coupon_fixture_server.py create mode 100644 scripts/ci/coupon_fixtures.py create mode 100644 scripts/ci/run_coupon_budget_a2a.py create mode 100644 scripts/ci/run_coupon_budget_mock_a2a.py create mode 100644 src/iac_code/pipeline/selling/references/coupons.md create mode 100644 tests/pipeline/selling_solution_first/test_cost_field_compatibility.py create mode 100644 tests/scripts/test_coupon_budget_a2a.py create mode 100644 tests/scripts/test_coupon_fixtures.py diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 232ca5ff9..a58fa21db 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -106,7 +106,29 @@ uv run --no-sync python scripts/ci/run_e2e.py --case ssf-a2a-happy-multi-plan \ ## CI 接入 -CI 调用同一个入口,并用 `--run-dir` 指定报告目录。`fast` 适合快速检查,`full` 适合完整确定性验收。真实场景必须提供测试专用配置目录和 `--allow-cloud-write`。配置文件内容应通过 CI 的 Secret 注入,且不要进入代码、作业输出或上传附件。 +`coupon-budget-a2a` 使用真实模型、HTTP/SSE A2A 和只读云 API,验证默认月费、明确小时与时长后的估价,以及当前部署中的优惠券查询不被分流。它也验证优惠券指南中的两条 API、字段与分页,不购买资源或实际使用优惠券: + +```bash +uv run python scripts/ci/run_e2e.py --case coupon-budget-a2a --suite live \ + --credential-source-dir /path/to/test-config --text-model glm-5.2-fast-preview + +# 仅验证指南 API,使用当前配置的云凭证,不调用模型 +uv run python scripts/ci/run_coupon_budget_a2a.py --api-only --allow-real-cloud \ + --run-dir /tmp/coupon-api-check +``` + +API 验证需要账号内实际存在优惠券及可查询的商品记录;空列表不能作为券字段和商品查询已验证的证明。API/模型原始结果留在私有运行目录,不上传为公开报告。该用例验证只读识别与规划,不证明特定订单或账单一定能抵扣。 + +`coupon-budget-mock-a2a` 补充没有真实可用券时的判断覆盖:A2A Server、模型和方案规划均使用真实实现,只在测试进程中替换 `DescribeCoupon`、`DescribeCouponItemList` 的业务返回;其它 BSS 操作在该测试进程中不开放。覆盖可用余额、余额不足、包年包月限制、GPU 排除、范围未知、过期、满减门槛、商品黑名单与查询失败九种情况。每种情况固定使用测试输入“8 小时 GPU 计算费 240 元”,核对模型表格中的抵扣额、需补现金及继续等待选案的流程状态;这不是 GPU 实时报价或实际结算证明。 + +```bash +uv run python scripts/ci/run_e2e.py --case coupon-budget-mock-a2a --suite live \ + --credential-source-dir /path/to/test-config --text-model glm-5.2-fast-preview +``` + +Mock 入口只位于 `scripts/ci/`,不修改应用运行时,也没有生产环境开关。真实结果与模拟结果使用不同用例名、报告目录及 `coupon_api_mocked` 标记;不可把该用例当成真实账单抵扣通过。 + +CI 调用同一个入口,并用 `--run-dir` 指定报告目录。`fast` 适合快速检查,`full` 适合完整确定性验收。真实场景必须提供测试专用配置目录;包含云写入的用例还需 `--allow-cloud-write`。配置文件内容应通过 CI 的 Secret 注入,且不要进入代码、作业输出或上传附件。 `report.md` 是概要表,`report.html` 可展开每个用例,`summary.json` 和各用例 `ci-result.json` 是结构化详情,`junit.xml` 供 CI 测试报告展示。确定性套件可上传 stdout/stderr;真实套件仅上传筛选后的状态文件,不上传原始日志、凭证、工作目录或场景原始 summary。 @@ -127,7 +149,7 @@ REPL 旧场景的 PTY 等待每 10 秒检查一次,每 60 秒在单用例的 s 先按 `report.md` 找到失败用例与首次失败检查,再看对应的 `ci-result.json`、场景 `summary.json`、日志和源码。Agent 应做有界复现,明确归类为产品缺陷、用例/断言缺陷、环境/凭证故障、云资源清理故障或超时,并写出证据、受影响场景和建议修复。配置的一次自动重跑之外,真实云用例再次复现前先核对残留资源与清理结果。报告里的“初步线索”只是索引,不能替代复盘结论。 -总入口目前登记 195 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 +总入口目前登记 198 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 ### 验收边界 diff --git a/scripts/ci/coupon_fixture_server.py b/scripts/ci/coupon_fixture_server.py new file mode 100644 index 000000000..2ed1b4cf0 --- /dev/null +++ b/scripts/ci/coupon_fixture_server.py @@ -0,0 +1,56 @@ +"""Test-process-only A2A bootstrap: substitute BSS results, preserve the real LLM.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from unittest.mock import patch + +from scripts.ci.coupon_fixtures import CASES, response + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--calls", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + args = parser.parse_args() + from iac_code.cli.main import app + from iac_code.tools.base import ToolResult + from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi + + original = AliyunApi._execute_runtime_unattached + + async def execute(tool, *, api_input, **kwargs): + product = str(api_input.get("product", "")).casefold() + action = str(api_input.get("action", "")) + if product in {"bssopenapi", "bss-openapi", "bss"}: + case = json.loads(args.state.read_text(encoding="utf-8"))["case"] + if case not in CASES: + return ToolResult.error("Unknown coupon fixture") + params = api_input.get("params") or {} + with args.calls.open("a", encoding="utf-8") as out: + out.write(json.dumps({"case": case, "action": action, "mocked": True}) + "\n") + if case == "query_denied": + return ToolResult.error("AccessDenied: not authorized to query the current account's coupons.") + if api_input.get("version") != "2023-09-30": + return ToolResult.error("Coupon fixtures require the documented 2023-09-30 API version.") + if action not in {"DescribeCoupon", "DescribeCouponItemList"}: + return ToolResult.error("Only documented coupon read APIs are supported by this fixture.") + return ToolResult.success(json.dumps(response(case, action, params), ensure_ascii=False)) + if not action.startswith(("Describe", "Get", "List", "Query")): + with args.calls.open("a", encoding="utf-8") as out: + out.write(json.dumps({"blocked_cloud_write": True, "action": action}) + "\n") + return ToolResult.error("Cloud writes are blocked by this read-only test server.") + return await original(tool, api_input=api_input, **kwargs) + + # This entry point is never imported by the application or activated by a production flag. + with patch.object(AliyunApi, "_execute_runtime_unattached", execute): + sys.argv = [sys.argv[0], "a2a", "--config", str(args.config)] + app() + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/coupon_fixtures.py b/scripts/ci/coupon_fixtures.py new file mode 100644 index 000000000..4d113e157 --- /dev/null +++ b/scripts/ci/coupon_fixtures.py @@ -0,0 +1,86 @@ +"""Synthetic BSS business responses for real-model coupon acceptance tests only.""" + +from __future__ import annotations + +import copy +import uuid +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class CouponCase: + title: str + cash: int | None + requires_items: bool = False + + +CASES = { + "applicable": CouponCase("300 元可用余额覆盖 240 元计算费", 0, True), + "balance_60": CouponCase("面值 300、余额 60,需补现金 180", 180, True), + "prepaid_only": CouponCase("仅限包年包月新购,不适用按量账单", 240), + "gpu_excluded": CouponCase("支持 ECS,但明确排除 GPU", 240), + "scope_unknown": CouponCase("只确认 ECS 商品,GPU 条件不足", None, True), + "expired": CouponCase("有余额但已经过期", 240), + "threshold": CouponCase("满 1000 减 300,本次 240 不达门槛", 240), + "ecs_blacklisted": CouponCase("ECS 在不适用商品黑名单", 240, True), + "query_denied": CouponCase("查询权限不足,不能解释为没有券", None), +} + + +def coupon(case: str) -> dict[str, Any]: + index = list(CASES).index(case) + value: dict[str, Any] = { + "CouponId": 9900000000000 + index, + "CouponNo": f"STUDENT-300-{index:02d}", + "CouponType": "VOUCHER", "CouponTypeName": "抵用券", + "Amount": "300.00", "RemainAmount": "300.00", "Currency": "CNY", + "Status": "AVAILABLE", "StartTime": "2026-01-01T00:00Z", "EndTime": "2099-01-01T00:00Z", + "YhOrderTypes": ["阿里云按量付费账单"], "MoneyLimit": "无订单金额限制", + "OrderTimeRule": "按量付费账单无最低使用时长限制", "FirstBuy": False, + "ItemNames": ["云服务器ECS"], "SuitItemType": "white", + "Remark": ( + "学生优惠券。适用于 cn-beijing 地域的 ecs.gn6i-c16g1.4xlarge GPU 实例按量计算费用," + "无首购或活动渠道限制。系统盘、EIP 和公网流量费用不在抵扣范围内。" + ), + } + if case == "balance_60": + value["RemainAmount"] = "60.00" + elif case == "prepaid_only": + value.update(YhOrderTypes=["新购"], OrderTimeRule="仅包年包月新购订单", Remark="仅限包年包月新购。") + elif case == "gpu_excluded": + value["Remark"] = "仅支持非 GPU ECS 按量计算费用;明确排除所有 GPU 实例。" + elif case == "scope_unknown": + value["Remark"] = "学生券;实例规格、GPU、地域及活动渠道限制需另行核实。" + elif case == "expired": + value.update(Status="EXPIRED", StartTime="2019-01-01T00:00Z", EndTime="2020-01-01T00:00Z") + elif case == "threshold": + value.update(CouponType="CERTAIN", CouponTypeName="满减券", MoneyLimit="满1000元减300元") + elif case == "ecs_blacklisted": + value.update(SuitItemType="black", Remark="不适用商品以商品黑名单为准;黑名单优先。") + return value + + +def response(case: str, action: str, params: dict[str, Any]) -> dict[str, Any]: + """Honor the documented IDs and pagination instead of returning a canned page.""" + value = coupon(case) + if action == "DescribeCoupon": + rows = [value] + if params.get("CouponId") is not None and str(params["CouponId"]) != str(value["CouponId"]): + rows = [] + elif action == "DescribeCouponItemList": + rows = [{"Code": "vm", "Name": "云服务器ECS"}] + if str(params.get("CouponId", "")) != str(value["CouponId"]): + rows = [] + else: + raise ValueError("Unsupported coupon fixture action") + page = int(params.get("CurrentPage", 1)) + size = int(params.get("PageSize", 20)) + if page < 1 or not 1 <= size <= 100: + raise ValueError("Invalid coupon fixture pagination") + return { + # Do not expose the scenario name or expected decision in model-visible IDs. + "RequestId": str(uuid.uuid5(uuid.NAMESPACE_URL, f"coupon-test/{case}/{action}/{page}")).upper(), + "CurrentPage": page, "PageSize": size, "TotalCount": len(rows), + "Data": copy.deepcopy(rows[(page - 1) * size:page * size]), + } diff --git a/scripts/ci/run_coupon_budget_a2a.py b/scripts/ci/run_coupon_budget_a2a.py new file mode 100644 index 000000000..e8cbccdb9 --- /dev/null +++ b/scripts/ci/run_coupon_budget_a2a.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Verify coupon guide APIs and period-aware deployment planning with real services. + +Use run_e2e.py for isolated real-LLM HTTP/SSE runs. --api-only exercises every +documented API recipe with the configured cloud identity, without an LLM. +No cloud resource writes, purchases, coupon changes or model fixtures are used. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import re +import sys +import time +from decimal import Decimal +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + fetch_pipeline_state, + stream_message, + wait_for_server, +) +from scripts.ci.run_promotional_offer_followup_a2a import ( # noqa: E402 + completion_inputs, + pipeline_facts, + visible_pipeline_text, + walk, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +PROMPTS = ( + "在阿里云北京给我推荐一个 GPU 云服务器方案,显存 8–16 GiB,内存至少 32 GiB," + "用于推理测试。请展示方案等我选择,当前不要部署或创建任何云资源。", + "我不是说按小时租吗?只租 8 小时,GPU 显存和内存要求不变。先更新方案估算," + "不要选择方案或进入部署。", + "我有 300 元学生优惠券,想用于刚才这个 GPU 部署方案,能抵扣按量付费吗?" + "我不想再补现金。请查一下当前账号的券,说明哪些适用条件能确认、哪些还不清楚。" + "不要替我领券、用券、下单或部署。", +) + + +def hourly_costs_match(outlines: list[str], details: list[str]) -> bool: + """Priced items need the requested unit; free topology resources have no rate.""" + def hourly(value: str) -> bool: + # Check the stated estimate period, not a later explanation such as "非月费". + period = re.search(r"小时|hours?|月|months?", value, re.I) + return period is not None and period.group().casefold() in {"小时", "hour", "hours"} + + def free(value: str) -> bool: + return bool(re.fullmatch(r"\s*(?:(?:[¥¥]\s*)?0(?:\.0+)?|免费|无费用)(?:\s*[((].*[))])?\s*", value)) + + return bool(outlines) and all(hourly(value) for value in outlines) and all( + hourly(value) or free(value) for value in details + ) + + +async def verify_apis(run_dir: Path) -> dict[str, Any]: + from iac_code.services.providers.aliyun import AliyunCredentials + from iac_code.tools.base import ToolContext + from iac_code.tools.cloud.aliyun.runtime import create_aliyun_runtime_services + + runtime = create_aliyun_runtime_services(cache_dir=run_dir / "api-cache") + runtime.credential_provider = lambda: AliyunCredentials.refresh_oauth_if_needed(AliyunCredentials.load()) + evidence: list[dict[str, Any]] = [] + + async def call(action: str, params: dict[str, Any]) -> dict[str, Any]: + result = await runtime.internal_caller.call( + tool_input={ + "product": "BssOpenApi", "version": "2023-09-30", "action": action, + "region_id": "cn-hangzhou", "params": params, + }, + context=ToolContext(cwd=str(run_dir), pipeline_mode=True), + ) + if result.is_error: + raise RuntimeError(f"{action} did not return a successful business result") + body = json.loads(result.content) + if not isinstance(body.get("Data"), list): + raise ValueError(f"{action} returned an unexpected Data shape") + evidence.append({ + "action": action, "version": "2023-09-30", "page": params["CurrentPage"], + "count": len(body["Data"]), "total": body["TotalCount"], + "fields": sorted({key for item in body["Data"] for key in item}), + }) + return body + + try: + coupons = await call("DescribeCoupon", {"CurrentPage": 1, "PageSize": 20}) + required = { + "CouponId", "CouponType", "CouponTypeName", "Amount", "RemainAmount", "Currency", + "Status", "StartTime", "EndTime", "YhOrderTypes", "MoneyLimit", "OrderTimeRule", + "FirstBuy", "Remark", "ItemNames", "SuitItemType", + } + if not coupons["Data"]: + raise RuntimeError("A real coupon is required to verify the documented fields and item query") + present = {key for coupon in coupons["Data"] for key in coupon} + if not required <= present: + raise ValueError("Documented coupon fields have not all been observed in real responses") + for coupon in coupons["Data"]: + Decimal(coupon["Amount"]) + Decimal(coupon["RemainAmount"]) + if coupons["TotalCount"] > len(coupons["Data"]): + await call("DescribeCoupon", {"CurrentPage": 2, "PageSize": 20}) + items = await call("DescribeCouponItemList", { + "CouponId": int(coupons["Data"][0]["CouponId"]), "CurrentPage": 1, "PageSize": 20, + }) + if not items["Data"] or not all({"Code", "Name"} <= item.keys() for item in items["Data"]): + raise ValueError("A non-empty real coupon item list is required to verify Code and Name") + if items["TotalCount"] > len(items["Data"]): + await call("DescribeCouponItemList", { + "CouponId": int(coupons["Data"][0]["CouponId"]), "CurrentPage": 2, "PageSize": 20, + }) + return {"passed": True, "calls": evidence, "settlement_verified": False} + finally: + await runtime.aclose() + + +def tool_inputs(config_dir: Path) -> list[dict[str, Any]]: + found: dict[str, dict[str, Any]] = {} + for path in sorted((config_dir / "projects").rglob("session.jsonl")): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") == "tool_use": + found[str(node["id"])] = { + "id": node["id"], "name": node.get("name"), "input": node.get("input", {}), + } + return list(found.values()) + + +def successful_coupon_queries(config_dir: Path) -> set[str]: + successful = set() + for path in (config_dir / "projects").rglob("session.jsonl"): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") != "tool_result" or node.get("is_error") is not False: + continue + try: + body = json.loads(node.get("content", "")) + except (ValueError, TypeError): + continue + if isinstance(body, dict) and isinstance(body.get("Data"), list) and "TotalCount" in body: + successful.add(node["tool_use_id"]) + return successful + + +def run_a2a(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run through run_e2e.py with isolated configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", "IAC_CODE_PROVIDER": args.provider, + "IAC_CODE_MODEL": args.model, "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + ) + checks: dict[str, bool] = {} + turns = [] + context_id = task_id = "" + try: + server.start() + wait_for_server(f"http://127.0.0.1:{port}", timeout=60, owned_server=server) + for index, prompt in enumerate(PROMPTS): + started = time.monotonic() + before = len(tool_inputs(config_dir)) + summary = stream_message( + server_url=f"http://127.0.0.1:{port}", cwd=str(workspace), prompt=prompt, + context_id=context_id, task_id=task_id, name=f"turn-{index + 1}", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + context_id, task_id = summary.context_id, summary.task_id + fetch_pipeline_state( + server_url=f"http://127.0.0.1:{port}", context_id=context_id, task_id=task_id, + run_dir=args.run_dir, name=f"turn-{index + 1}", redaction_env=env, + ) + conclusion, steps = pipeline_facts(args.run_dir / f"turn-{index + 1}.events.jsonl") + completions = completion_inputs(config_dir) + conclusion = conclusion or (completions[-1] if completions else {}) + calls = tool_inputs(config_dir)[before:] + outline_costs = [ + str(candidate.get("total_cost", candidate.get("total_monthly_cost", ""))) + for call in calls if call["name"] == "show_architecture_plan" + for candidate in call["input"].get("candidates", []) + ] + detail_costs = [ + str(resource.get("rough_cost", resource.get("rough_monthly_cost", ""))) + for call in calls if call["name"] == "show_candidate_detail" + for resource in call["input"].get("resource_inventory", []) + if resource.get("rough_cost", resource.get("rough_monthly_cost")) + ] + text = visible_pipeline_text(args.run_dir / f"turn-{index + 1}.events.jsonl") + checks[f"turn {index + 1}: real HTTP/SSE response"] = summary.response_content_type == "text/event-stream" + checks[f"turn {index + 1}: remains in deployment planning"] = ( + not summary.normal_handoff_ready and conclusion.get("status") != "rejected" + and "materialize_selected_candidate" not in steps + and summary.last_status_state == "TASK_STATE_INPUT_REQUIRED" + ) + if index == 0: + checks["default estimate is monthly"] = bool(outline_costs) and all( + re.search(r"月|month", cost, re.I) for cost in outline_costs + ) + elif index == 1: + checks["explicit hourly request updates total and details"] = hourly_costs_match( + outline_costs, detail_costs, + ) + checks["eight-hour duration is explained"] = bool(re.search( + r"8\s*(?:个)?\s*小时|8\s*hours?|八小时", text + json.dumps(calls, ensure_ascii=False), re.I, + )) + else: + checks["coupon guide was read on demand"] = any( + call["name"] == "read_file" and "coupons.md" in json.dumps(call["input"]) + for call in calls + ) + checks["coupon inquiry uses a real cloud API"] = any( + call["name"] == "aliyun_api" and call["input"].get("action") == "DescribeCoupon" + and call["id"] in successful_coupon_queries(config_dir) + for call in calls + ) + checks["coupon inquiry does not redirect to assistant"] = "ros.console.aliyun.com/" not in text + turns.append({ + "turn": index + 1, "seconds": round(time.monotonic() - started, 2), + "task_state": summary.last_status_state, "outline_costs": outline_costs, + "detail_costs": detail_costs, "started_steps": steps, + "tools": [call["name"] for call in calls], + }) + print(json.dumps({"model": args.model, **turns[-1]}, ensure_ascii=False), flush=True) + calls = tool_inputs(config_dir) + legacy_cost_fields = {"total_monthly_cost", "rough_monthly_cost", "monthly_range", "monthly_cost"} + checks["planning tool inputs use neutral cost names"] = all( + not legacy_cost_fields.intersection(node) + for call in calls if call["name"] in {"show_architecture_plan", "show_candidate_detail"} + for node in walk(call["input"]) + ) + checks["no cloud writes or deployment tools"] = all( + call["input"].get("action", "").startswith(("Describe", "Get", "List", "Query")) + for call in calls if call["name"] == "aliyun_api" + ) and not any(call["name"].startswith("ros_deploy") for call in calls) + finally: + server.terminate() + records = observe.stop() + for name in (".credentials.yml", ".cloud-credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["requested real model only"] = observed == [args.model] + return {"checks": checks, "turns": turns, "observed_models": observed} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", default="deepseek-v4.1-flash") + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--python") + parser.add_argument("--timeout", type=float, default=420) + parser.add_argument("--api-only", action="store_true") + parser.add_argument("--allow-real-cloud", action="store_true") + args = parser.parse_args() + if not args.allow_real_cloud: + parser.error("--allow-real-cloud is required") + args.run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + logging.disable(logging.CRITICAL) + from loguru import logger + logger.remove() + result: dict[str, Any] = {"scenario": "coupon-budget-a2a", "cleanup_status": "not-needed"} + try: + result["api_verification"] = asyncio.run(verify_apis(args.run_dir)) + if not args.api_only: + result.update(run_a2a(args)) + result["status"] = "passed" if all(result.get("checks", {}).values()) else "failed" + except Exception as exc: + result.update(status="failed", error_type=type(exc).__name__) + (args.run_dir / "summary.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False), flush=True) + return 0 if result["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_coupon_budget_mock_a2a.py b/scripts/ci/run_coupon_budget_mock_a2a.py new file mode 100644 index 000000000..3ab82dcd5 --- /dev/null +++ b/scripts/ci/run_coupon_budget_mock_a2a.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Real HTTP/SSE A2A + real LLM, with only BSS coupon results substituted. + +The 240 CNY compute charge is a test input, not a current GPU quote. Other +read-only cloud calls retain the configured real credential and transport. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from decimal import Decimal +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + stream_message, + wait_for_server, +) +from scripts.ci.coupon_fixtures import CASES, coupon # noqa: E402 +from scripts.ci.run_coupon_budget_a2a import tool_inputs # noqa: E402 +from scripts.ci.run_promotional_offer_followup_a2a import ( # noqa: E402 + pipeline_facts, + visible_pipeline_text, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +SETUP = ( + "准备在阿里云北京按量租一台 GPU 服务器,指定 ecs.gn6i-c16g1.4xlarge," + "显存 16 GiB、内存 62 GiB,只用 8 小时。请给一个方案让我选择,暂不部署," + "不创建、修改或删除任何云资源。" +) +QUESTION = ( + "继续刚才北京的 GPU 部署方案,我现在换用另一张面值 300 元的学生券,条件可能不同," + "请重新查询当前账号的券和适用商品,不沿用之前的券查询结果。" + "本次只核对 GPU 计算部分:按量使用 8 小时,控制台给出的计算费合计 240 元;" + "请按这 240 元分析用券,不另查价格。系统盘、EIP、公网流量不包括在这 240 元内,其他费用另算。" + "我希望这部分不补现金。请用表格分别列出 GPU 计算费、券的可用余额、" + "本次计算费可抵扣金额、计算部分需补现金、结论依据;" + "无法确认的填写待确认,不要猜测。暂不选择方案、不重新规划、不下单或用券。" +) + + +def answer_cell(text: str, label: str) -> str: + """Accept vertical or horizontal Markdown tables without using unrelated amounts.""" + rows = [ + [cell.strip() for cell in line.strip().strip("|").split("|")] + for line in text.splitlines() if line.strip().startswith("|") + ] + for index, cells in enumerate(rows): + # Only a row immediately above a Markdown separator is a horizontal + # header. A data cell may mention cash without naming the cash column. + if index + 2 >= len(rows) or not all( + re.fullmatch(r"[\s:-]+", item) for item in rows[index + 1] + ): + continue + for column, cell in enumerate(cells): + if re.search(label, cell) and len(rows[index + 2]) > column: + return rows[index + 2][column] + for cells in rows: + if len(cells) >= 2 and re.search(label, cells[0]): + return cells[1] + return "" + + +def cash_cell(text: str) -> str: + return answer_cell(text, r"现金|自付|自费|差价") + + +def amount_matches(cell: str, expected: int | None) -> bool: + uncertainty = re.sub(r"待确认项[::\s]*无|无待确认项", "", cell) + if expected is None: + return bool(re.search(r"待确认|未知|不确定|无法|未确认|不能确认|待核实|如果|若|假设", uncertainty)) + if re.search(r"待确认|未知|不确定|无法确认|未确认", uncertainty) and not re.search(r"预计|估算|预估", cell): + return False + amounts = [Decimal(value.replace(",", "")) for value in re.findall(r"\d[\d,]*(?:\.\d+)?", cell)] + return bool(amounts) and amounts[0] == Decimal(expected) + + +def evaluate_answer(case: str, text: str) -> dict[str, bool]: + cash = CASES[case].cash + coverage = None if cash is None else 240 - cash + return { + "cash calculation or unknown matches evidence": amount_matches(cash_cell(text), cash), + "deduction calculation or unknown matches evidence": amount_matches( + answer_cell(text, r"抵扣金额|抵扣费用"), coverage, + ), + } + + +def recorded_calls(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def run(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run through run_e2e.py with isolated configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + active = args.run_dir / "coupon-fixture-state.json" + ledger = args.run_dir / "coupon-fixture-calls.jsonl" + active.write_text(json.dumps({"case": "applicable"}), encoding="utf-8") + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", "IAC_CODE_PROVIDER": args.provider, + "IAC_CODE_MODEL": args.model, "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + server_args=[ + "-m", "scripts.ci.coupon_fixture_server", "--state", str(active), + "--calls", str(ledger), "--config", str(config), + ], + ) + checks: dict[str, bool] = {} + rows = [] + server_url = f"http://127.0.0.1:{port}" + try: + server.start() + wait_for_server(server_url, timeout=60, owned_server=server) + setup = stream_message( + server_url=server_url, cwd=str(workspace), prompt=SETUP, name="setup", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + checks["real setup waits for selection"] = ( + setup.last_status_state == "TASK_STATE_INPUT_REQUIRED" and not setup.normal_handoff_ready + ) + if not checks["real setup waits for selection"]: + raise RuntimeError("Real-model setup did not reach the planning selection boundary") + context_id, task_id = setup.context_id, setup.task_id + for case in args.scenario or CASES: + active.write_text(json.dumps({"case": case}), encoding="utf-8") + start = time.monotonic() + before = len(recorded_calls(ledger)) + prior_tool_ids = {call["id"] for call in tool_inputs(config_dir)} + summary = stream_message( + server_url=server_url, cwd=str(workspace), prompt=QUESTION, + context_id=context_id, task_id=task_id, name=case, + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + context_id, task_id = summary.context_id, summary.task_id + path = args.run_dir / f"{case}.events.jsonl" + text = visible_pipeline_text(path) + # Fixtures contain no real user data. Keep the model answer for human review. + (args.run_dir / f"{case}.answer.txt").write_text(text, encoding="utf-8") + conclusion, steps = pipeline_facts(path) + calls = recorded_calls(ledger)[before:] + cell = cash_cell(text) + local = { + "real HTTP/SSE": summary.response_content_type == "text/event-stream", + "queried current coupon fixture": any( + call.get("case") == case and call.get("action") == "DescribeCoupon" + and call.get("mocked") is True for call in calls + ), + **evaluate_answer(case, text), + "stays in deployment planning": ( + summary.last_status_state == "TASK_STATE_INPUT_REQUIRED" + and not summary.normal_handoff_ready and conclusion.get("status") != "rejected" + and "materialize_selected_candidate" not in steps + ), + "no assistant handoff": not summary.normal_handoff_ready and conclusion.get("status") != "rejected", + } + if CASES[case].requires_items: + local["queried item scope"] = any( + call.get("case") == case and call.get("action") == "DescribeCouponItemList" for call in calls + ) + local["item query uses the current coupon ID"] = any( + call["id"] not in prior_tool_ids and call["name"] == "aliyun_api" + and call["input"].get("action") == "DescribeCouponItemList" + and str(call["input"].get("params", {}).get("CouponId")) == str(coupon(case)["CouponId"]) + and int(call["input"].get("params", {}).get("CurrentPage", 1)) == 1 + for call in tool_inputs(config_dir) + ) + if case == "applicable": + local["compute coverage is quantified"] = "240" in text + local["settlement caveat remains"] = bool(re.search(r"账单|结算|以实际|实际抵扣", text)) + for key, value in local.items(): + checks[f"{case}: {key}"] = value + row = { + "case": case, "title": CASES[case].title, "expected_cash": CASES[case].cash, + "cash_cell": cell, "passed": all(local.values()), "checks": local, + "assistant_reference_present": "ros.console.aliyun.com/" in text, + "seconds": round(time.monotonic() - start, 2), + } + rows.append(row) + print(json.dumps({"model": args.model, **row}, ensure_ascii=False), flush=True) + if summary.normal_handoff_ready: + break + all_calls = recorded_calls(ledger) + checks["no cloud writes reached transport"] = not any(c.get("blocked_cloud_write") for c in all_calls) + checks["all selected cases completed"] = len(rows) == len(args.scenario or CASES) + checks["coupon guide was actually read"] = any( + call["name"] == "read_file" and "coupons.md" in json.dumps(call["input"]) + for call in tool_inputs(config_dir) + ) + finally: + server.terminate() + records = observe.stop() + for name in (".credentials.yml", ".cloud-credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["only requested real model ran"] = observed == [args.model] + return { + "status": "passed" if all(checks.values()) else "failed", "checks": checks, "cases": rows, + "model": args.model, "observed_models": observed, + "coupon_api_mocked": True, "llm_mocked": False, "settlement_verified": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", default="glm-5.2-fast-preview") + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--python") + parser.add_argument("--scenario", choices=tuple(CASES), action="append") + parser.add_argument("--timeout", type=float, default=420) + parser.add_argument("--allow-real-cloud", action="store_true") + args = parser.parse_args() + if not args.allow_real_cloud: + parser.error("--allow-real-cloud is required for the remaining real read-only APIs and LLM") + args.run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + result: dict[str, Any] = {"scenario": "coupon-budget-mock-a2a", "cleanup_status": "not-needed"} + try: + result.update(run(args)) + except Exception as exc: + result.update(status="failed", error_type=type(exc).__name__) + (args.run_dir / "summary.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False), flush=True) + return 0 if result["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_e2e.py b/scripts/ci/run_e2e.py index e8319f915..5a13e9125 100644 --- a/scripts/ci/run_e2e.py +++ b/scripts/ci/run_e2e.py @@ -308,6 +308,14 @@ def _selling_group(spec: Any) -> str: "promotional-offer-followup-a2a", "scripts/ci/run_promotional_offer_followup_a2a.py", (), 1200, "live", live_runner="promotional_offer_followup", group="readonly", ), + Case( + "coupon-budget-a2a", "scripts/ci/run_coupon_budget_a2a.py", (), 1800, "live", + live_runner="coupon_budget", group="readonly", + ), + Case( + "coupon-budget-mock-a2a", "scripts/ci/run_coupon_budget_mock_a2a.py", (), 3600, "live", + live_runner="coupon_budget_mock", group="readonly", + ), Case("smoke-a2a-vpc", "scripts/a2a/smoke/test_a2a_vpc.py", (), 800, "live", live_runner="smoke", group="smoke"), Case("smoke-acp-vpc", "scripts/acp/smoke/test_acp_vpc.py", (), 450, "live", live_runner="smoke", group="smoke"), Case( @@ -513,7 +521,10 @@ def _read_summary(case_dir: Path, source: str) -> dict[str, Any] | None: def _live_cleanup_status(case: Case, summary: dict[str, Any] | None) -> str: - if case.live_runner in {"selector", "agui_selector", "canary", "legacy_a2a_readonly"} | LLM_ONLY_RUNNERS: + readonly_runners = { + "selector", "agui_selector", "canary", "legacy_a2a_readonly", "coupon_budget", "coupon_budget_mock", + } + if case.live_runner in readonly_runners | LLM_ONLY_RUNNERS: return "not-needed" if summary is None: return "unverified" diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 3cc28dac8..126005681 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -6194,10 +6194,10 @@ msgstr "Wichtigste Abwägung bei Kosten, Verfügbarkeit oder Komplexität" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates muss ein nicht leeres Array eindeutiger Übersichten mit " -"candidate_name, summary, total_monthly_cost und key_tradeoff sein" +"candidate_name, summary, total_cost und key_tradeoff sein" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po index e9a01e43d..10dbe8a6d 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po @@ -4342,3 +4342,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Der Link konnte nicht geöffnet werden. Bitte erneut versuchen." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Geschätzte Kosten" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 78739c42f..8e6a10f34 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -6148,10 +6148,10 @@ msgstr "La contrapartida más importante de coste, disponibilidad o complejidad" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates debe ser un array no vacío de resúmenes únicos con " -"candidate_name, summary, total_monthly_cost y key_tradeoff" +"candidate_name, summary, total_cost y key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po index c23ddb40a..aab10fa94 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po @@ -4331,3 +4331,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "No se pudo abrir el enlace. Inténtalo de nuevo." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Coste estimado" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index d6c9a3565..1fb41efbc 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -6158,10 +6158,10 @@ msgstr "" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates doit être un tableau non vide de résumés uniques contenant " -"candidate_name, summary, total_monthly_cost et key_tradeoff" +"candidate_name, summary, total_cost et key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po index 2c0505d26..9681be0d9 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po @@ -4345,3 +4345,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Impossible d’ouvrir le lien. Veuillez réessayer." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Coût estimé" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index ecbbd231a..bdb41a037 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -5782,9 +5782,9 @@ msgstr "コスト、可用性、または複雑性に関する最も重要なト #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" -"candidates は candidate_name、summary、total_monthly_cost、key_tradeoff " +"candidates は candidate_name、summary、total_cost、key_tradeoff " "を含む、一意な概要の空でない配列である必要があります" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po index 020528f08..7199582dc 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po @@ -4180,3 +4180,7 @@ msgstr "ローカルランタイムを再起動できませんでした。詳細 #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "リンクを開けませんでした。もう一度お試しください。" + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "概算費用" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 429f158e9..3cd2685b3 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -6099,10 +6099,10 @@ msgstr "A contrapartida mais importante de custo, disponibilidade ou complexidad #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates deve ser um array não vazio de resumos exclusivos com " -"candidate_name, summary, total_monthly_cost e key_tradeoff" +"candidate_name, summary, total_cost e key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po index 19990d106..07981bd3d 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po @@ -4317,3 +4317,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Não foi possível abrir o link. Tente novamente." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Custo estimado" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 07a89e2ee..e07242b9c 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -5710,10 +5710,10 @@ msgstr "最重要的成本、可用性或复杂度取舍" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates 必须是非空且不重名的候选方案概览数组,每项包含 " -"candidate_name、summary、total_monthly_cost 和 key_tradeoff" +"candidate_name、summary、total_cost 和 key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po index ac4fa5d9c..d0b0fb6dd 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po @@ -4172,3 +4172,7 @@ msgstr "本地运行时无法重新启动。请打开诊断目录查看详情。 #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "无法打开链接,请重试。" + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "费用估算" diff --git a/src/iac_code/pipeline/selling/references/coupons.md b/src/iac_code/pipeline/selling/references/coupons.md new file mode 100644 index 000000000..db8223f61 --- /dev/null +++ b/src/iac_code/pipeline/selling/references/coupons.md @@ -0,0 +1,72 @@ +# 阿里云优惠券查询与预算判断 + +## 使用原则 + +用户为部署方案询问优惠券、代金券或抵扣预算时,保留部署目标和规格,按需查询当前账号的券及适用条件。不要仅凭“学生券”“通用券”或面值,承诺可以用于目标配置,也不要因为用券问题中断部署规划。 + +可用、不适用或待确认只是用券结果,不代表用户取消部署;回答后以 `status: awaiting_selection` 保留候选等待选择,不提交 `rejected`。 + +券面值、剩余可抵扣金额与用户愿意补付的现金是三件事。只有用户明确要求不补现金时,才把可确认的抵扣额度作为可覆盖费用的上限;未明确时不要自行认定愿意补差价。无需为普通部署查询优惠券。 + +## 查询优惠券 + +使用当前会话的云凭证,只查询当前账号,不设置跨账号查询参数: + +```python +aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCoupon", + region_id="cn-hangzhou", params={"CurrentPage": 1, "PageSize": 20}) +``` + +返回 `Data` 为券数组,`CurrentPage`、`PageSize`、`TotalCount` 表示分页。需要继续寻找目标券时递增 `CurrentPage`;未读完列表时不能宣称账号没有该券。不要只按面值选择券;结合用户说明、券类型、状态和限制识别,多个候选无法区分时再澄清。不同券可能省略部分字段,缺失值保留未知,不当作零余额、无限制或已满足资格。 + +| 信息 | 返回字段与处理 | +|---|---| +| 券标识与类型 | `CouponId`、`CouponType`、`CouponTypeName`;不把不同类型的券都当作现金余额 | +| 金额 | `Amount` 为面值,`RemainAmount` 为余额,金额为字符串;计算时使用十进制金额并核对 `Currency` | +| 状态与期限 | `Status`、`StartTime`、`EndTime`;时间带时区,状态与当前时间均需核对,不能只看余额 | +| 消费场景 | `YhOrderTypes`,核对是否包含本次新购、续费或按量付费账单等场景 | +| 金额与购买时长限制 | `MoneyLimit`、`OrderTimeRule`;仅使用返回的明确规则,空值不等于无限制 | +| 首购等限制 | `FirstBuy`、`Remark`;有首购限制不代表当前账号已经满足资格 | +| 商品范围 | `ItemNames`、`SuitItemType`,必要时按下节查询编码;不能忽略白名单或黑名单语义 | + +适用场景和门槛按该张券的实际字段判断,不根据券类型统一推断“抵用券无门槛”或“满减券不能用于按量付费”。 + +## 查询适用商品 + +用上一接口返回的真实 `CouponId`,不要猜测或复用示例 ID: + +```python +aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCouponItemList", + region_id="cn-hangzhou", params={ + "CouponId": coupon_id, "CurrentPage": 1, "PageSize": 20 + }) +``` + +`Data` 为商品列表,每项包含 `Code` 和 `Name`,同样按 `TotalCount` 分页。结合该券的 `SuitItemType` 解释结果:`white` 表示指定商品适用,`black` 表示指定商品不适用,`all` 表示全产品通用范围;不能把黑名单中的商品当成可用商品。 + +商品编码和名称不是实例规格清单。即使包含 ECS,也不能仅凭这一项确认所有 GPU 规格、地域、付费方式或活动渠道均可用;缺少这些条件的证据时明确写“目标配置适用性待确认”。 + +## 判定结果 + +- **明确不可抵扣**:券已过期、用尽或作废,或者当前方案违反已返回的付费方式、商品排除、GPU 限制、金额门槛等规则。该券对本次费用的可抵扣金额为 0,不能再假设“也许仍能抵扣”。`Status` 为 `EXPIRED` 或 `EndTime` 已早于当前时间时,即使 `RemainAmount` 仍有数字,也不是有效余额;若只有这一张券,已知的本次费用需全部另付现金。 +- **条件性可抵扣**:券有效且余额、适用商品及所有已知限制均匹配时,按下面的费用范围计算;仍保留实际结算说明。 +- **待确认**:查询失败或缺少判断目标配置所必需的信息。不要把缺失信息当成已满足,也不要把已经明确不满足的条件重新写成待确认。 + +## 结合方案解释费用 + +- 保持配置、地域、数量、计费方式和使用时长一致,再比较费用与可抵扣额度。按小时租用时使用有依据的按量单价,不用包月价除以固定小时数替代。 +- 分别说明资源粗估、已确认的券余额与适用条件,以及未确认的抵扣范围。优惠券余额不是折后资源单价,不直接从月费中减掉券面值来生成“每月优惠价”。 +- 只有代金券的适用范围及其限制已确认、且预计费用全部在可抵扣范围内时,才可给出条件性估算:预计需补现金为 `max(预计费用 - 可用余额, 0)`;仍注明实际抵扣以订单或账单为准。满减、折扣、门槛、部分费用不适用或多券叠加均不得套用该简式。 +- 仅明确按小时付费、未给出使用时长时,先展示小时粗估。只有抵扣条件足够明确时,才估算余额可覆盖的大致时长,并列出未覆盖的云盘、网络等费用;不能承诺券用完就自动停机或不再扣费。 + +## 查询失败与能力边界 + +接口成功但列表为空,只表示本次查询未返回券;权限不足、认证失败、分页未完成或服务异常都不能解释为没有券。保留当前部署方案,说明具体未知项,不编造抵扣结果。 + +所需只读权限分别为 `bss:DescribeCoupon` 和 `bss:DescribeCouponItemList`(资源 `*`);费用中心或子账号还可能因折扣查询权限受限,按接口实际错误提示处理,不自行改权限。查询 API 不会领取、使用、冻结或验证实际结算抵扣。 + +## 官方参考 + +- [DescribeCoupon:优惠券、余额与限制字段](https://help.aliyun.com/zh/user-center/developer-reference/api-bssopenapi-2023-09-30-describecoupon) +- [DescribeCouponItemList:商品编码与名称](https://help.aliyun.com/zh/user-center/developer-reference/api-bssopenapi-2023-09-30-describecouponitemlist) +- [优惠券规则与适用条件](https://help.aliyun.com/zh/user-center/how-to-use-coupons) diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py b/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py index 66fa5bc09..b52e25f0a 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py @@ -23,6 +23,9 @@ on_resource_observed, on_rollback_cleanup_required, ) +from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( + normalize_solution_selection_costs, +) logger = logging.getLogger(__name__) @@ -133,6 +136,7 @@ def evaluate_deployment_gate(selected_plan: Any) -> str: def on_enter(ctx: PipelineContext) -> None: """Record the deployment gate verdict inside the existing ``selected_plan`` conclusion.""" + normalize_solution_selection_costs(ctx.snapshot().get("solution_selection")) selected_plan = ctx.get_conclusion("selected_plan") error = evaluate_deployment_gate(selected_plan) if not isinstance(selected_plan, dict): diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py b/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py index 4a64e3d82..ffab44d63 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py @@ -15,6 +15,9 @@ from iac_code.pipeline.engine.complete_step_tool import CompletionEnrichmentError from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.ui_contract import parse_deployment_confirmation +from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( + normalize_solution_selection_costs, +) from iac_code.tools.cloud.aliyun.ros_yaml import ros_yaml_load __all__ = [ @@ -68,6 +71,7 @@ def on_enter(ctx: PipelineContext) -> None: """Record candidate resolver state in Step 1 context for prompts and the deploy gate.""" selection = ctx.get_conclusion("solution_selection") + normalize_solution_selection_costs(selection) index, candidate, error = resolve_authoritative_candidate(selection) if not isinstance(selection, dict): return diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py b/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py index d80fe0261..f81869837 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py @@ -15,6 +15,8 @@ detail_record_matches, latest_candidate_detail_records, latest_candidate_outline_batch, + normalize_candidate_costs, + normalize_inventory_costs, ) __all__ = ["build_prompt_context", "enrich_completion_input"] @@ -50,15 +52,16 @@ def build_prompt_context() -> dict[str, Any]: ("cons", 1), ) -_COMPOSED_MONTHLY_PRICE_RE = re.compile( +_COST_PERIOD_RE = r"(?:小时|天|日|周|月|年|hours?|days?|weeks?|months?|years?)" +_COMPOSED_PRICE_RE = re.compile( r"^\s*(?:约\s*)?(?:" r"[¥¥]\s*[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:[¥¥]\s*)?[\d,.]+)?" - r"\s*(?:元|CNY|RMB)?\s*(?:(?:[//]\s*)?月|每月)?" + rf"\s*(?:元|CNY|RMB)?\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" r"|[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:[¥¥]\s*)?[\d,.]+)?" - r"\s*(?:元\s*(?:(?:[//]\s*)?月)?|(?:[//]\s*)月|每月)" + rf"\s*(?:元\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?|[//]\s*{_COST_PERIOD_RE}|每{_COST_PERIOD_RE})" r"|(?:CNY|RMB)\s*[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:CNY|RMB)?\s*[\d,.]+)?" - r"\s*(?:(?:[//]\s*)?月|每月)?" - r"|(?:免费|零费用|无费用)\s*(?:(?:[//]\s*)?月|每月)?" + rf"\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" + rf"|(?:免费|零费用|无费用)\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" r")\s*$", re.IGNORECASE, ) @@ -246,7 +249,7 @@ def _candidate_from_outline_and_detail( outline: dict[str, str], detail: dict[str, Any], ) -> dict[str, Any]: - inventory = copy.deepcopy(detail.get("resource_inventory")) + inventory = normalize_inventory_costs(detail.get("resource_inventory")) inventory = inventory if isinstance(inventory, list) else [] return { "name": outline["candidate_name"], @@ -257,7 +260,7 @@ def _candidate_from_outline_and_detail( "resource_inventory": inventory, "rough_cost": { "currency": "CNY", - "monthly_range": outline["total_monthly_cost"], + "cost_range": outline["total_cost"], "items": _cost_items_from_inventory(inventory), "assumptions": copy.deepcopy(detail.get("cost_assumptions") or []), "exclusions": copy.deepcopy(detail.get("cost_exclusions") or []), @@ -280,7 +283,7 @@ def _cost_items_from_inventory(inventory: list[Any]) -> list[dict[str, str]]: { "name": str(raw.get("product") or raw.get("resource_id") or "").strip(), "spec": spec, - "monthly_cost": str(raw.get("rough_monthly_cost") or "").strip(), + "cost": str(raw.get("rough_cost") or "").strip(), } ) return items @@ -302,7 +305,7 @@ def _normalize_candidate( if not isinstance(summary, str) or not summary.strip(): raise CompletionEnrichmentError(_("candidates[{index}].summary must be non-empty").format(index=index)) - normalized = copy.deepcopy(candidate) + normalized = normalize_candidate_costs(candidate) normalized["candidate_id"] = f"candidate-{index}" normalized["name"] = name.strip() normalized["summary"] = summary.strip() @@ -420,7 +423,7 @@ def _topology_text(candidate: dict[str, Any]) -> str: def _candidate_option_summary(candidate: dict[str, Any]) -> str: - """Compose 「概述;月度价格;首要代价」, idempotently. + """Compose 「概述;当前周期价格;首要代价」, idempotently. 重新规划时模型在自己的上下文里看到的是上一轮拼好的选项文案,会把它原样当成候选概述 交回来,于是价格与代价在方案卡里出现两遍。价格前的分隔符是上一轮拼接留下的标记, @@ -428,9 +431,9 @@ def _candidate_option_summary(candidate: dict[str, Any]) -> str: """ summary = str(candidate.get("summary") or "") rough_cost = candidate.get("rough_cost") - monthly_range = rough_cost.get("monthly_range") if isinstance(rough_cost, dict) else None + cost_range = rough_cost.get("cost_range") if isinstance(rough_cost, dict) else None notes = candidate.get("cons") - price = str(monthly_range or "") + price = str(cost_range or "") tradeoff = str(notes[0]) if isinstance(notes, list) and notes else "" summary = _strip_composed_option_tail(summary, current_price=price) summary = summary.strip() @@ -451,6 +454,6 @@ def _strip_composed_option_tail(summary: str, *, current_price: str) -> str: # A projected option always has content before the price and a trade-off after it. # Requiring a complete price-shaped segment avoids trimming ordinary prose that uses semicolons. for index in range(1, len(segments) - 1): - if _COMPOSED_MONTHLY_PRICE_RE.fullmatch(segments[index]): + if _COMPOSED_PRICE_RE.fullmatch(segments[index]): return ";".join(segments[:index]) return summary diff --git a/src/iac_code/pipeline/selling_solution_first/pipeline.yaml b/src/iac_code/pipeline/selling_solution_first/pipeline.yaml index 654ef8578..6b16ff34f 100644 --- a/src/iac_code/pipeline/selling_solution_first/pipeline.yaml +++ b/src/iac_code/pipeline/selling_solution_first/pipeline.yaml @@ -299,28 +299,28 @@ steps: billing_method: type: string description: 用于粗估的计费方式,例如按量付费。 - rough_monthly_cost: + rough_cost: type: string - description: 该清单项的月度粗估区间或免费说明。 + description: 该清单项的粗估区间及周期,默认按月,用户指定单位或时长时与其一致。 lifecycle: type: string enum: [create, use_existing, reference, forbid] description: 与 resource_intents 一致的资源生命周期。 rough_cost: type: object - description: Step 1 的架构级月度粗估,不是 ROS 精确询价结果。 - required: [currency, monthly_range, items, assumptions, exclusions, confidence] + description: 架构级费用粗估,默认按月,用户指定单位或时长时与其一致;不是 ROS 精确询价结果。 + required: [currency, cost_range, items, assumptions, exclusions, confidence] properties: currency: type: string enum: [CNY] description: 粗估费用币种,固定为 CNY。 - monthly_range: + cost_range: type: string - description: 面向用户的月度总费用区间,例如 ¥500~¥800/月。 + description: 面向用户的总费用区间及实际周期,例如 ¥500~¥800/月或 ¥3~¥5/小时。 items: type: array - description: 构成月度粗估的主要计费项。 + description: 构成当前周期粗估的主要计费项。 items: type: object description: 一项产品或资源的粗估费用。 @@ -331,9 +331,9 @@ steps: spec: type: string description: 采用的粗估规格与数量。 - monthly_cost: + cost: type: string - description: 该项月度粗估区间或免费说明。 + description: 该项粗估区间及实际周期,须与总费用使用同一单位和时长。 assumptions: type: array description: 粗估成立所依据的地域、用量、规格和计费假设。 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md index 8b7ccd375..ec072fe77 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md @@ -11,7 +11,7 @@ user_invocable: false 本流程只支持阿里云。用户明确要求 AWS、Azure、GCP、腾讯云、华为云等非阿里云平台时,不要输出对应平台资源,不要把它作为支持的基础设施需求继续推进;必须先澄清是否改为阿里云目标,或将其作为不支持/非阿里云需求结束。 -本步骤不生成模板、不询价、不执行任何云写操作。 +本步骤不生成模板、不做完整询价验证、不执行任何云写操作。 ## 第一部分:意图分类 @@ -248,7 +248,7 @@ CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分 "quantity": 2, "recommended_spec": "2 vCPU / 4 GiB,最终规格以库存和询价为准", "billing_method": "包年包月或按量付费", - "rough_monthly_cost": "¥400~¥700/月", + "rough_cost": "¥400~¥700/月", "lifecycle": "create" } ``` @@ -299,8 +299,8 @@ CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分 ```json { "currency": "CNY", - "monthly_range": "¥1800~¥2600/月", - "items": [{"name": "ECS", "spec": "2 vCPU / 4 GiB × 2", "monthly_cost": "¥400~¥700/月"}], + "cost_range": "¥1800~¥2600/月", + "items": [{"name": "ECS", "spec": "2 vCPU / 4 GiB × 2", "cost": "¥400~¥700/月"}], "assumptions": ["地域 cn-hangzhou", "按量付费", "ECS 2 台"], "exclusions": ["公网流量费", "日志写入量", "跨地域流量"], "confidence": "low" @@ -308,6 +308,9 @@ CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分 ``` - 只给区间,不要求精确到个位;费用估算基于阿里云公开定价的合理范围。 +- 默认按月估算;用户明确指定小时、天、周或年时,总价、资源明细和预算比较均使用该单位。另给出使用时长时,同时说明该时长的预计总费用;仅指定计费单位时,不擅自假定使用时长。 +- 用户修改周期或时长时,保留未改变的规格与部署目标,更新费用及其假设。不能用包月价直接推导按量单价,或拿不同周期的金额判断超预算。 +- 金额字符串必须明确标出实际估算周期,不能把小时、天或年费用显示为月费。 - 必须在 `assumptions` 中说明地域、计费方式、数量和规格假设。 - 套餐型产品可复用上下文中的目标地域套餐参考信息,信息不足时按需查询;粗估不额外验证活动资格或逐套餐询价。说明价格来源和购买时长,不凭假设的促销价使方案满足预算;用户提供的活动价注明适用条件尚未确认。 - 不包含的费用必须显式列入 `exclusions`,例如公网流量、短信、日志写入量、跨地域流量。 @@ -315,6 +318,8 @@ CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分 - 有用户预算硬约束时,候选区间上限原则上不得超过预算;确实无法满足时先澄清或明确标记冲突,不得静默放宽预算。 - 本阶段**不调用** `ros_estimate_template_cost`,也**不输出** `OriginalAmount`/`TradeAmount`。精确询价由实现步骤完成。 +部署需求涉及优惠券、代金券或用券预算时,先用 `read_file` 按需读取 [优惠券指南](references/coupons.md),再识别与查询券的适用条件;同一上下文已读取时直接复用。此类问题留在部署流程内,不因提到优惠券而分流或拒绝部署。 + ### 方案说服力 方案卡直接展示 `decision_notes`,它决定用户能不能判断「为什么该选这个方案」。四个字段必填,且不得为空数组: @@ -365,7 +370,7 @@ CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分 展示分两阶段执行: 1. 先调用一次 `show_architecture_plan(candidates=[...])`,提交本轮**全部**轻量候选摘要。每项只包含 - `candidate_name`、`summary`、`total_monthly_cost` 和 `key_tradeoff`。数组顺序定义 0 基候选坐标, + `candidate_name`、`summary`、`total_cost` 和 `key_tradeoff`。数组顺序定义 0 基候选坐标, 不得在此工具中提交 `nodes`、`edges`、资源清单或详细价格项。用户要求增减方案时重新提交修改后的 完整摘要数组,不提交增量 patch。 2. 摘要批次成功后,按 0 基下标逐个调用 `show_candidate_detail`。每个模型轮次只细化一个候选,参数中的 @@ -384,7 +389,7 @@ CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分 没有成功摘要批次时不得调用 `show_candidate_detail`;详情数量、下标或名称不完整时不得调用 `complete_step`。某个详情失败时只修正并重试该候选。 -候选的 `summary`、`rough_cost.monthly_range` 和 `decision_notes` 应足够支持 Python 生成紧凑 options;不要只给泛化名称。 +候选的 `summary`、`rough_cost.cost_range` 和 `decision_notes` 应足够支持 Python 生成紧凑 options;不要只给泛化名称。 ### 处理选择 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json index c7c16568d..cdb5f87de 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json @@ -73,7 +73,7 @@ {"name": "single_candidate", "check": "candidates 数组长度为 1"}, {"name": "no_extra_resources", "check": "products 中只包含 VPC,不额外创建 VSwitch、SecurityGroup、ECS、RDS 或 SLB"}, {"name": "still_awaits_selection", "check": "status 为 awaiting_selection,即使只有一个候选也要用户明确选择"}, - {"name": "shows_outline_batch", "check": "先调用一次 show_architecture_plan,candidates 仅含 candidate_name、summary、total_monthly_cost、key_tradeoff"}, + {"name": "shows_outline_batch", "check": "先调用一次 show_architecture_plan,candidates 仅含 candidate_name、summary、total_cost、key_tradeoff"}, {"name": "shows_candidate_detail", "check": "再调用一次 show_candidate_detail,提交下标 0 的 topology_graph、resource_inventory、费用假设和 decision_notes"}, {"name": "compact_completion", "check": "complete_step 只提交 status 和 intent,不提交完整 candidates"} ] @@ -90,7 +90,7 @@ {"name": "meaningful_diff", "check": "方案之间的 products 组合或 topology 有实质差异,不是仅规格不同"}, {"name": "has_resource_inventory", "check": "每个候选的 resource_inventory 非空,且每条含 product、purpose、quantity、lifecycle"}, {"name": "has_topology_graph", "check": "每个候选的 topology_graph.nodes 非空,且 edges 的 source/target 都引用已定义的节点 id"}, - {"name": "has_rough_cost", "check": "每个候选的 rough_cost 含 monthly_range、assumptions 和 exclusions"}, + {"name": "has_rough_cost", "check": "每个候选的 rough_cost 含 cost_range、assumptions 和 exclusions"}, {"name": "no_exact_pricing", "check": "不调用 ros_estimate_template_cost,不输出 OriginalAmount/TradeAmount"} ] }, @@ -100,7 +100,7 @@ "prompt": "部署一个API服务,月预算500元以内", "expected_behavior": "在预算约束下给出 1-2 个候选,rough_cost 区间上限不超过预算,assumptions 说明地域和计费方式", "assertions": [ - {"name": "within_budget", "check": "所有候选 rough_cost.monthly_range 上限不超过 500 CNY"}, + {"name": "within_budget", "check": "所有候选 rough_cost.cost_range 上限不超过 500 CNY"}, {"name": "no_overengineering", "check": "不包含 SLB 集群、多可用区等超出预算的高可用配置"}, {"name": "documents_assumptions", "check": "rough_cost.assumptions 说明地域、计费方式和数量假设"}, {"name": "budget_recorded_as_constraint", "check": "预算写入 intent.budget_constraint,并作为硬约束快照传入候选"} diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md index d03635dd1..3fdab0121 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md @@ -1 +1 @@ -ROS console override: For standalone billing, promotions/renewals, availability/pricing or traffic-offset queries, and promotional-offer followups during selection, briefly address technical suitability, then visibly link {ai_assistant_destination}. Call `complete_step` with `status: rejected`, copying that linked reply into `rejection_reason`; override clarification/reselection. Questions are not selections. Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. +ROS console override: Except deployment coupon applicability/budget (follow coupon guide), standalone billing, promotions/renewals, pricing/availability, traffic offsets and promotional-offer followups during selection first visibly link {ai_assistant_destination}; `complete_step` with `status: rejected` and linked reply in `rejection_reason`; override clarification/reselection. Questions are not selections. Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. diff --git a/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py b/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py index 8058e0ee7..c3b49f415 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py @@ -7,6 +7,7 @@ from __future__ import annotations +import copy from typing import Any @@ -71,8 +72,9 @@ def normalize_outline_candidates(value: Any) -> list[dict[str, str]] | None: if not isinstance(item, dict): return None candidate: dict[str, str] = {} - for field in ("candidate_name", "summary", "total_monthly_cost", "key_tradeoff"): - raw = item.get(field) + for field in ("candidate_name", "summary", "total_cost", "key_tradeoff"): + # Old persisted outline records remain readable, but schemas expose only total_cost. + raw = item.get(field, item.get("total_monthly_cost") if field == "total_cost" else None) if not isinstance(raw, str) or not raw.strip(): return None candidate[field] = raw.strip() @@ -83,6 +85,52 @@ def normalize_outline_candidates(value: Any) -> list[dict[str, str]] | None: return normalized +def _rename_cost_field(value: dict[str, Any], old: str, new: str) -> None: + if old in value: + if new not in value: + value[new] = value[old] + del value[old] + + +def normalize_inventory_costs(value: Any) -> Any: + """Copy legacy resource costs into the period-neutral planning contract.""" + inventory = copy.deepcopy(value) + if isinstance(inventory, list): + for resource in inventory: + if isinstance(resource, dict): + _rename_cost_field(resource, "rough_monthly_cost", "rough_cost") + return inventory + + +def normalize_candidate_costs(value: Any) -> Any: + """Migrate only known planning fields, preserving values and canonical-key precedence.""" + candidate = copy.deepcopy(value) + if not isinstance(candidate, dict): + return candidate + if "resource_inventory" in candidate: + candidate["resource_inventory"] = normalize_inventory_costs(candidate["resource_inventory"]) + cost = candidate.get("rough_cost") + if isinstance(cost, dict): + _rename_cost_field(cost, "monthly_range", "cost_range") + items = cost.get("items") + if isinstance(items, list): + for item in items: + if isinstance(item, dict): + _rename_cost_field(item, "monthly_cost", "cost") + return candidate + + +def normalize_solution_selection_costs(value: Any) -> None: + """Normalize restored candidates in place without changing context versions or stale flags.""" + if not isinstance(value, dict): + return + candidates = value.get("candidates") + if isinstance(candidates, list): + value["candidates"] = [normalize_candidate_costs(candidate) for candidate in candidates] + if "selected_candidate" in value: + value["selected_candidate"] = normalize_candidate_costs(value["selected_candidate"]) + + def latest_candidate_detail_records( records: list[dict[str, Any]], batch: CandidateOutlineBatch, @@ -147,4 +195,7 @@ def _record_sequence(record: dict[str, Any], position: int) -> int: "latest_candidate_detail_records", "latest_candidate_outline_batch", "normalize_outline_candidates", + "normalize_inventory_costs", + "normalize_candidate_costs", + "normalize_solution_selection_costs", ] diff --git a/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py b/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py index e4468d60e..299f24087 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py @@ -49,7 +49,7 @@ def name(self) -> str: def description(self) -> str: return _( "Display one complete batch of lightweight candidate outlines before rich details are generated. " - "Submit every current candidate in order with its name, summary, monthly estimate and key trade-off. " + "Submit every current candidate in order with its name, summary, cost estimate and key trade-off. " "Do not include topology nodes, resource inventory or detailed cost items." ) @@ -79,10 +79,13 @@ def input_schema(self) -> dict[str, Any]: "minLength": 1, "description": _("Short product combination and architecture summary"), }, - "total_monthly_cost": { + "total_cost": { "type": "string", "minLength": 1, - "description": _("Rough monthly range, such as ¥230~¥380/month"), + "description": _( + "Rough cost range including its period: monthly by default, " + "or the user's requested unit and duration." + ), }, "key_tradeoff": { "type": "string", @@ -90,7 +93,7 @@ def input_schema(self) -> dict[str, Any]: "description": _("The most important cost, availability or complexity trade-off"), }, }, - "required": ["candidate_name", "summary", "total_monthly_cost", "key_tradeoff"], + "required": ["candidate_name", "summary", "total_cost", "key_tradeoff"], }, }, }, @@ -110,7 +113,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> return ToolResult.error( _( "candidates must be a non-empty array of unique outlines with candidate_name, summary, " - "total_monthly_cost and key_tradeoff" + "total_cost and key_tradeoff" ) ) records = self._completion_guard_state.get("tool_result_records") @@ -134,7 +137,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> candidate_name=candidate["candidate_name"], summary=candidate["summary"], cost_items=[], - total_monthly_cost=candidate["total_monthly_cost"], + total_monthly_cost=candidate["total_cost"], candidate_index=candidate_index, candidate_set_id=candidate_set_id, detail_stage="outline", diff --git a/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py b/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py index 8607e1269..a1c99e743 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py @@ -10,6 +10,7 @@ from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( first_missing_candidate_detail_index, latest_candidate_outline_batch, + normalize_inventory_costs, ) from iac_code.pipeline.selling_solution_first.tools.show_architecture_plan_tool import ( render_architecture_graph, @@ -33,7 +34,7 @@ def description(self) -> str: return _( "Display the rich detail for exactly one candidate from the latest show_architecture_plan batch. " "Call once per model turn in candidate index order. Include resource lifecycle intent, topology graph, " - "resource inventory, cost assumptions and decision notes; do not repeat summary or monthly total." + "resource inventory, cost assumptions and decision notes; do not repeat summary or cost total." ) @property @@ -126,7 +127,12 @@ def input_schema(self) -> dict[str, Any]: "quantity": {"type": "integer", "minimum": 1}, "recommended_spec": {"type": "string"}, "billing_method": {"type": "string"}, - "rough_monthly_cost": {"type": "string"}, + "rough_cost": { + "type": "string", + "description": ( + "Rough cost including its period, matching the outline's unit and duration." + ), + }, "lifecycle": { "type": "string", "enum": ["create", "use_existing", "reference", "forbid"], @@ -236,7 +242,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> candidate_name=expected_name, summary=outline["summary"], cost_items=cost_items, - total_monthly_cost=outline["total_monthly_cost"], + total_monthly_cost=outline["total_cost"], candidate_index=expected_index, candidate_set_id=batch.candidate_set_id, detail_stage="detail", @@ -283,7 +289,8 @@ def _cost_items_from_inventory(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): return [] items: list[dict[str, str]] = [] - for raw in value: + # CandidateDetailEvent is the compatibility boundary for existing UI clients. + for raw in normalize_inventory_costs(value): if not isinstance(raw, dict): continue product = str(raw.get("product") or raw.get("resource_id") or "").strip() @@ -295,7 +302,7 @@ def _cost_items_from_inventory(value: Any) -> list[dict[str, str]]: { "name": product, "spec": spec, - "monthly_cost": str(raw.get("rough_monthly_cost") or "").strip(), + "monthly_cost": str(raw.get("rough_cost") or "").strip(), } ) return items diff --git a/src/iac_code/web/static/diagram-preview.html b/src/iac_code/web/static/diagram-preview.html index a4680ff42..130f42a65 100644 --- a/src/iac_code/web/static/diagram-preview.html +++ b/src/iac_code/web/static/diagram-preview.html @@ -106,6 +106,6 @@

- + diff --git a/src/iac_code/web/static/index.html b/src/iac_code/web/static/index.html index 6d25083b7..eb78c77e9 100644 --- a/src/iac_code/web/static/index.html +++ b/src/iac_code/web/static/index.html @@ -479,6 +479,6 @@

- + diff --git a/src/iac_code/web/static/js/app.js b/src/iac_code/web/static/js/app.js index d3f051cc7..0fdbdc868 100644 --- a/src/iac_code/web/static/js/app.js +++ b/src/iac_code/web/static/js/app.js @@ -8,14 +8,14 @@ import { deploymentConfirmationKey, renderDeploymentConfirmationPanel, renderPipelineWorkspace, -} from "./components/pipeline.js?v=pipeline-solution-confirm-v16"; +} from "./components/pipeline.js?v=pipeline-solution-confirm-v17"; import { renderToolCards, applyShimmerPhase, applySpinPhase } from "./components/tool_cards.js?v=live-inline-tools-v26"; import { createWorkspaceController } from "./components/workspace.js?v=cloud-creds-v60"; -import { createOutputController, highlightTemplate } from "./components/output_panel.js?v=output-panel-v38"; +import { createOutputController, highlightTemplate } from "./components/output_panel.js?v=output-panel-v39"; import { openImageLightbox } from "./components/image_lightbox.js?v=image-lightbox-v1"; import { reduceEvent } from "./events.js?v=web-repl-ui-324"; import { applyDomI18n, t } from "./i18n.js?v=web-repl-ui-277"; -import { renderMermaid } from "./mermaid_render.js?v=arch-diagram-v5"; +import { renderMermaid } from "./mermaid_render.js?v=cost-period-v6"; const root = document.getElementById("iac-code-web-root"); const DESKTOP_RUNTIME = diff --git a/src/iac_code/web/static/js/architecture_diagram.js b/src/iac_code/web/static/js/architecture_diagram.js index 3e2ace9d3..7b835c932 100644 --- a/src/iac_code/web/static/js/architecture_diagram.js +++ b/src/iac_code/web/static/js/architecture_diagram.js @@ -1,5 +1,5 @@ import { t } from "./i18n.js?v=web-repl-ui-277"; -import { renderMermaid } from "./mermaid_render.js?v=arch-diagram-v5"; +import { renderMermaid } from "./mermaid_render.js?v=cost-period-v6"; import { getArchitectureDiagramRenderer } from "./api.js?v=web-repl-ui-313"; const RENDERER_EVENT = "iac-code:architecture-renderer-changed"; diff --git a/src/iac_code/web/static/js/components/output_panel.js b/src/iac_code/web/static/js/components/output_panel.js index 66eccf05c..c32c858de 100644 --- a/src/iac_code/web/static/js/components/output_panel.js +++ b/src/iac_code/web/static/js/components/output_panel.js @@ -3,8 +3,8 @@ import { t } from "../i18n.js?v=web-repl-ui-277"; import { disposeArchitectureDiagram, renderArchitectureDiagram, -} from "../architecture_diagram.js?v=eraser-browser-v14"; -import { renderDiagramPrice } from "../mermaid_render.js?v=arch-diagram-v5"; +} from "../architecture_diagram.js?v=eraser-browser-v15"; +import { renderDiagramPrice } from "../mermaid_render.js?v=cost-period-v6"; const STATUS_LABELS = { true: t("Success"), false: t("In progress / failed") }; const DIAGRAM_PREVIEW_MIN_WIDTH = 560; diff --git a/src/iac_code/web/static/js/components/pipeline.js b/src/iac_code/web/static/js/components/pipeline.js index 6f6c2100a..b32d6e613 100644 --- a/src/iac_code/web/static/js/components/pipeline.js +++ b/src/iac_code/web/static/js/components/pipeline.js @@ -2,8 +2,8 @@ import { t } from "../i18n.js?v=web-repl-ui-277"; import { disposeArchitectureDiagram, renderArchitectureDiagram, -} from "../architecture_diagram.js?v=eraser-browser-v14"; -import { renderDiagramPrice } from "../mermaid_render.js?v=arch-diagram-v5"; +} from "../architecture_diagram.js?v=eraser-browser-v15"; +import { renderDiagramPrice } from "../mermaid_render.js?v=cost-period-v6"; function text(value) { return value === undefined || value === null ? "" : String(value); diff --git a/src/iac_code/web/static/js/diagram_preview.js b/src/iac_code/web/static/js/diagram_preview.js index 208d06cb7..15a6b4a06 100644 --- a/src/iac_code/web/static/js/diagram_preview.js +++ b/src/iac_code/web/static/js/diagram_preview.js @@ -1,4 +1,4 @@ -import { renderArchitectureDiagram } from "./architecture_diagram.js?v=eraser-browser-v14"; +import { renderArchitectureDiagram } from "./architecture_diagram.js?v=eraser-browser-v15"; import { indexTemplateFiles, shuffledOtherTemplatePaths, diff --git a/src/iac_code/web/static/js/mermaid_render.js b/src/iac_code/web/static/js/mermaid_render.js index 4abf3d187..847be793c 100644 --- a/src/iac_code/web/static/js/mermaid_render.js +++ b/src/iac_code/web/static/js/mermaid_render.js @@ -108,7 +108,7 @@ export function renderDiagramPrice(item) { totalRow.className = "diagram-price-total"; const label = document.createElement("span"); label.className = "diagram-price-label"; - label.textContent = t("Estimated monthly cost"); + label.textContent = t("Estimated cost"); const value = document.createElement("span"); value.className = "diagram-price-value"; value.textContent = total || "—"; diff --git a/tests/a2a/test_pipeline_snapshot.py b/tests/a2a/test_pipeline_snapshot.py index da0a57819..f77129fe3 100644 --- a/tests/a2a/test_pipeline_snapshot.py +++ b/tests/a2a/test_pipeline_snapshot.py @@ -1327,8 +1327,8 @@ def test_solution_first_canonical_conclusion_keeps_full_candidate_in_snapshot() ], "rough_cost": { "currency": "CNY", - "monthly_range": "¥100/月", - "items": [{"name": "ECS", "spec": "ecs.e-c1m1.large", "monthly_cost": "¥100/月"}], + "cost_range": "¥100/月", + "items": [{"name": "ECS", "spec": "ecs.e-c1m1.large", "cost": "¥100/月"}], "assumptions": ["杭州地域"], "exclusions": ["流量费"], "confidence": "medium", diff --git a/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py b/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py index e2e0812e0..9d775086d 100644 --- a/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py +++ b/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +import yaml from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.pipeline_runner import PipelineRunner @@ -29,7 +30,7 @@ { "candidate_name": NAME, "summary": "One managed RDS instance in Hangzhou", - "total_monthly_cost": "CNY 800-1200/month", + "total_cost": "CNY 800-1200/month", "key_tradeoff": "Fixed monthly cost", } ], @@ -46,6 +47,7 @@ "product": "RDS", "purpose": "database", "quantity": 1, + "rough_cost": "CNY 800-1200/month", "lifecycle": "create", } ], @@ -66,6 +68,7 @@ class PlanningModel: def __init__(self): self.calls = 0 + self.materialization_inspected = False def get_model_name(self): return "deterministic-p18-fixture" @@ -99,6 +102,12 @@ async def stream(self, messages, system, tools=None, max_tokens=8192): }, ), ] + if self.calls == len(script) - 1 and "第二步" in system: + assert all(key not in system for key in ("monthly_range", "rough_monthly_cost", "monthly_cost")) + assert "CNY 800-1200/month" in system + self.materialization_inspected = True + yield MessageStartEvent(message_id="materialization-prompt-checked") + return assert self.calls < len(script), ( "Valid selection fell back to the model instead of advancing", [ @@ -117,8 +126,8 @@ async def stream(self, messages, system, tools=None, max_tokens=8192): yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) -@pytest.mark.parametrize("cold", [False, True], ids=["same-process", "cold-recovery"]) -async def test_idempotent_outline_survives_selection_resume(tmp_path, monkeypatch, cold): +@pytest.mark.parametrize("mode", ["same-process", "cold-recovery", "legacy-cold-recovery"]) +async def test_idempotent_outline_survives_selection_resume(tmp_path, monkeypatch, mode): monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) workspace = tmp_path / "workspace" @@ -151,11 +160,27 @@ def runner(*, resume=False): ) assert model.calls == 4 - if cold: + if mode == "legacy-cold-recovery": + # Simulate a sidecar written by the previous version, keeping state/version metadata intact. + context_path = flow.session.context_path + snapshot = yaml.safe_load(context_path.read_text()) + for candidate in snapshot["solution_selection"]["value"]["candidates"]: + cost = candidate["rough_cost"] + cost["monthly_range"] = cost.pop("cost_range") + for item in cost["items"]: + item["monthly_cost"] = item.pop("cost") + for resource in candidate["resource_inventory"]: + resource["rough_monthly_cost"] = resource.pop("rough_cost") + context_path.write_text(yaml.safe_dump(snapshot, allow_unicode=True)) + if mode != "same-process": # Construct a new runner/storage: restore the real sidecar and attempt # transcript from disk, without carrying the live completion guard. flow = runner(resume=True) - assert flow.context.get_conclusion("solution_selection") == saved + restored = flow.context.get_conclusion("solution_selection") + if mode == "legacy-cold-recovery": + assert "monthly_range" in restored["candidates"][0]["rough_cost"] + else: + assert restored == saved resumed = [] async with aclosing(flow.resume(encode_selected_candidate(NAME, 0))) as stream: @@ -167,8 +192,11 @@ def runner(*, resume=False): isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_STARTED and event.step_id == "materialize_selected_candidate" + and mode != "legacy-cold-recovery" ): break # The selection boundary is verified; do not create cloud resources. + if isinstance(event, MessageStartEvent) and event.message_id == "materialization-prompt-checked": + break selected = flow.context.get_conclusion("solution_selection") assert selected["status"] == "selected", ( @@ -179,6 +207,8 @@ def runner(*, resume=False): assert selected["selected_candidate_index"] == 0 assert selected["selected_candidate"] == saved["candidates"][0] assert model.calls == 4, "Selection must not regenerate candidates or call the model again" + if mode == "legacy-cold-recovery": + assert model.materialization_inspected, "Verify the real Step 2 prompt after legacy disk recovery" assert any( isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_STARTED diff --git a/tests/pipeline/selling_solution_first/test_completion_projection.py b/tests/pipeline/selling_solution_first/test_completion_projection.py index 5bdb32f37..e3dbedfe2 100644 --- a/tests/pipeline/selling_solution_first/test_completion_projection.py +++ b/tests/pipeline/selling_solution_first/test_completion_projection.py @@ -133,8 +133,8 @@ def _candidate_semantics() -> dict[str, Any]: ], "rough_cost": { "currency": "CNY", - "monthly_range": "¥800~¥1200/月", - "items": [{"name": "RDS", "monthly_cost": "¥800~¥1200/月"}], + "cost_range": "¥800~¥1200/月", + "items": [{"name": "RDS", "cost": "¥800~¥1200/月"}], "assumptions": ["cn-hangzhou"], "exclusions": [], "confidence": "medium", @@ -167,7 +167,7 @@ def _planning_records( { "candidate_name": str(candidate.get("name") or ""), "summary": str(candidate.get("summary") or ""), - "total_monthly_cost": str(rough_cost.get("monthly_range") or ""), + "total_cost": str(rough_cost.get("cost_range") or ""), "key_tradeoff": (str(cons[0]).strip() if cons else "") or "待进一步评估", } ) @@ -627,6 +627,28 @@ async def test_raw_and_runtime_failures_share_the_same_retry_budget(self): class TestStepOneProjection: + def test_legacy_outline_and_detail_records_project_into_neutral_costs(self, loaded): + candidate = _candidate_semantics() + candidate["resource_inventory"][0]["rough_cost"] = "¥8~¥12/小时" + candidate["rough_cost"]["cost_range"] = "¥8~¥12/小时" + records = _planning_records([candidate]) + for outline in records[0]["input"]["candidates"]: + outline["total_monthly_cost"] = outline.pop("total_cost") + for record in records[1:]: + for resource in record["input"].get("resource_inventory", []): + resource["rough_monthly_cost"] = resource.pop("rough_cost") + original = copy.deepcopy(records) + + result = _finalize( + _tool(_step(loaded, "solution_planning_and_selection"), records=records), + _awaiting_selection_delta(), + ) + projected = result.conclusion["candidates"][0] + assert projected["rough_cost"]["cost_range"] == "¥8~¥12/小时" + assert projected["rough_cost"]["items"][0]["cost"] == "¥8~¥12/小时" + assert projected["resource_inventory"][0]["rough_cost"] == "¥8~¥12/小时" + assert records == original, "Migration must not rewrite the historical audit records" + def test_awaiting_and_selected_deltas_expand_to_authoritative_candidates(self, loaded): step = _step(loaded, "solution_planning_and_selection") records = _planning_records([_candidate_semantics()]) @@ -818,12 +840,13 @@ def test_completion_error_reports_at_most_five_batch_problems(self, loaded): error = _assert_error(_tool(step, records=records), _awaiting_selection_delta(), "fully detailed") assert "1 more error(s) omitted" in error.message - def test_option_summary_keeps_semicolon_prose_and_replaces_old_price_tail(self, loaded): + @pytest.mark.parametrize("unit", ["月", "小时", "天", "周", "年", "hour", "day", "week", "year"]) + def test_option_summary_keeps_semicolon_prose_and_replaces_old_price_tail(self, loaded, unit): step = _step(loaded, "solution_planning_and_selection") candidate = _candidate_semantics() base = "复用现有网络;减少资源数量;适合测试环境" - candidate["summary"] = f"{base};¥80~¥300/月;旧代价" - candidate["rough_cost"]["monthly_range"] = "¥100~¥360/月" + candidate["summary"] = f"{base};¥80~¥300/{unit};旧代价" + candidate["rough_cost"]["cost_range"] = f"¥100~¥360/{unit}" candidate["decision_notes"]["cons"] = ["新代价"] summary = _finalize( @@ -831,7 +854,7 @@ def test_option_summary_keeps_semicolon_prose_and_replaces_old_price_tail(self, _awaiting_selection_delta(), ).conclusion["options"][0]["summary"] - assert summary == f"{base};¥100~¥360/月;新代价" + assert summary == f"{base};¥100~¥360/{unit};新代价" def test_status_only_awaiting_delta_reopens_saved_candidates_but_cannot_create_them(self, loaded): step = _step(loaded, "solution_planning_and_selection") diff --git a/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py b/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py new file mode 100644 index 000000000..0b1012363 --- /dev/null +++ b/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py @@ -0,0 +1,134 @@ +"""Period-neutral model contracts, persisted candidates and legacy display clients.""" + +import asyncio +import copy +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from iac_code.pipeline.engine.context import PipelineContext +from iac_code.pipeline.engine.loader import load_pipeline_dir +from iac_code.pipeline.engine.step_executor import StepExecutor +from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import normalize_candidate_costs +from iac_code.pipeline.selling_solution_first.tools.show_architecture_plan_tool import ShowArchitecturePlanTool +from iac_code.pipeline.selling_solution_first.tools.show_candidate_detail_tool import ShowCandidateDetailTool +from iac_code.tools.base import ToolContext, ToolRegistry +from iac_code.types.stream_events import CandidateDetailEvent + +PIPELINE = Path(__file__).resolve().parents[3] / "src/iac_code/pipeline/selling_solution_first" +LEGACY_FIELDS = {"total_monthly_cost", "rough_monthly_cost", "monthly_range", "monthly_cost"} + + +def legacy_candidate(): + return { + "candidate_id": "candidate-0", "name": "GPU", "summary": "Eight-hour GPU test", + "output_path": "templates/0-gpu.yml", + "resource_inventory": [{"resource_id": "gpu", "rough_monthly_cost": "¥240/8小时"}], + "rough_cost": { + "monthly_range": "¥240/8小时", "items": [{"name": "ECS", "monthly_cost": "¥240/8小时"}], + }, + } + + +@pytest.mark.parametrize("step_id", ["materialize_selected_candidate", "deploying"]) +def test_restored_candidates_are_neutral_before_rendering_prompts(step_id, tmp_path, monkeypatch): + monkeypatch.delenv("IAC_CODE_ROS_AGENT_AWARENESS", raising=False) + pipeline = load_pipeline_dir(PIPELINE) + ctx = PipelineContext(pipeline.context_dependencies) + candidate = legacy_candidate() + ctx.set_conclusion("solution_selection", { + "status": "selected", "continue_pipeline": True, + "candidates": [candidate], "selected_candidate": copy.deepcopy(candidate), + "selected_candidate_index": 0, "selected_candidate_name": "GPU", + }) + field = ctx.get_field("solution_selection") + field.stale = True + metadata = (field.version, field.stale, field.updated_at, copy.deepcopy(field.history)) + step = next(step for step in pipeline.steps if step.step_id == step_id) + step.on_enter(ctx) + + selection = ctx.get_conclusion("solution_selection") + for value in [selection["candidates"][0], selection["selected_candidate"]]: + assert value["rough_cost"]["cost_range"] == "¥240/8小时" + assert value["rough_cost"]["items"][0]["cost"] == "¥240/8小时" + assert value["resource_inventory"][0]["rough_cost"] == "¥240/8小时" + assert not any(key in json.dumps(value) for key in LEGACY_FIELDS) + assert (field.version, field.stale, field.updated_at, field.history) == metadata + assert candidate == legacy_candidate(), "Do not mutate retained historical candidate references" + + executor = StepExecutor( + provider_manager=SimpleNamespace(), base_tool_registry=ToolRegistry(), + pipeline=pipeline, pipeline_dir=PIPELINE, cwd=str(tmp_path), + ) + prompt = executor._build_full_system_prompt(step, ctx) + assert not any(key in prompt for key in LEGACY_FIELDS) + if step_id == "materialize_selected_candidate": + assert "¥240/8小时" in prompt + assert selection["selection_valid"] is True + + +def test_canonical_values_including_zero_take_precedence_over_legacy_values(): + candidate = legacy_candidate() + candidate["resource_inventory"][0]["rough_cost"] = "¥0/小时" + candidate["rough_cost"]["cost_range"] = "¥0/小时" + candidate["rough_cost"]["items"][0]["cost"] = 0 + migrated = normalize_candidate_costs(candidate) + assert migrated["rough_cost"]["cost_range"] == "¥0/小时" + assert migrated["rough_cost"]["items"][0]["cost"] == 0 + assert migrated["resource_inventory"][0]["rough_cost"] == "¥0/小时" + assert normalize_candidate_costs(migrated) == migrated + + +@pytest.mark.asyncio +async def test_new_outline_matches_legacy_record_without_changing_candidate_batch(): + legacy = {"candidate_name": "GPU", "summary": "8h GPU", "total_monthly_cost": "¥240/8小时", + "key_tradeoff": "Compute only"} + state = {"tool_result_records": [{ + "tool_name": "show_architecture_plan", "input": {"candidates": [legacy]}, + "is_error": False, "record_id": "original-batch", "sequence": 1, + }]} + current = {**legacy, "total_cost": legacy["total_monthly_cost"]} + del current["total_monthly_cost"] + queue = asyncio.Queue() + result = await ShowArchitecturePlanTool(state).execute( + tool_input={"candidates": [current]}, context=ToolContext(event_queue=queue, tool_use_id="new-call"), + ) + assert not result.is_error + assert result.metadata == {"candidate_set_id": "original-batch", "idempotent": True} + assert queue.empty() + + +@pytest.mark.asyncio +async def test_neutral_tools_keep_costs_in_the_existing_display_event_contract(): + outline = {"candidate_name": "GPU", "summary": "8h GPU", "total_cost": "¥240/8小时", + "key_tradeoff": "Compute only"} + state = {"tool_result_records": [{ + "tool_name": "show_architecture_plan", "input": {"candidates": [outline]}, + "is_error": False, "record_id": "batch", "sequence": 1, + }]} + queue = asyncio.Queue() + result = await ShowCandidateDetailTool(state).execute( + tool_input={ + "candidate_name": "GPU", "candidate_index": 0, + "topology_graph": {"nodes": [{"id": "gpu", "label": "GPU", "product": "ECS"}], "edges": []}, + "resource_inventory": [{"resource_id": "gpu", "product": "ECS", "rough_cost": "¥240/8小时"}], + }, context=ToolContext(event_queue=queue, tool_use_id="detail"), + ) + assert not result.is_error + event = queue.get_nowait() + assert isinstance(event, CandidateDetailEvent) + assert event.total_monthly_cost == "¥240/8小时" + assert event.cost_items == [{"name": "ECS", "spec": "", "monthly_cost": "¥240/8小时"}] + + +def test_all_current_model_contracts_and_skill_examples_avoid_legacy_cost_fields(): + pipeline = load_pipeline_dir(PIPELINE) + skill_root = PIPELINE / "skills/iac-aliyun-solution-first" + contracts = [ + ShowArchitecturePlanTool().input_schema, ShowCandidateDetailTool().input_schema, + pipeline.steps[0].conclusion_schema, + ] + rendered = json.dumps(contracts) + (skill_root / "SKILL.md").read_text() + (skill_root / "evals.json").read_text() + assert not any(key in rendered for key in LEGACY_FIELDS) diff --git a/tests/pipeline/selling_solution_first/test_packaging.py b/tests/pipeline/selling_solution_first/test_packaging.py index 29110be47..09af30d89 100644 --- a/tests/pipeline/selling_solution_first/test_packaging.py +++ b/tests/pipeline/selling_solution_first/test_packaging.py @@ -174,7 +174,7 @@ def test_step_one_links_product_guides_without_template_references(self): links = set(REFERENCE_LINK_PATTERN.findall(text)) # Product knowledge is read on demand; template/pricing workflows stay in Step 2. - assert links == {"references/cloud-products/"} + assert links == {"references/cloud-products/", "references/coupons.md"} assert "OriginPrice" not in text diff --git a/tests/pipeline/selling_solution_first/test_show_architecture_plan_tool.py b/tests/pipeline/selling_solution_first/test_show_architecture_plan_tool.py index 937710c85..a65bf57cb 100644 --- a/tests/pipeline/selling_solution_first/test_show_architecture_plan_tool.py +++ b/tests/pipeline/selling_solution_first/test_show_architecture_plan_tool.py @@ -40,7 +40,7 @@ def _tool_input(**overrides): "purpose": "应用计算", "quantity": 2, "recommended_spec": "2 vCPU / 4 GiB", - "rough_monthly_cost": "¥200~¥400/月", + "rough_cost": "¥200~¥400/月", "lifecycle": "create", } ], @@ -69,7 +69,7 @@ async def _run(tool_input) -> tuple[object, list]: { "candidate_name": "方案A:经典三层", "summary": "经典三层架构", - "total_monthly_cost": "¥200~¥400/月", + "total_cost": "¥200~¥400/月", "key_tradeoff": "组件较多", } ] @@ -110,7 +110,7 @@ def test_outline_schema_is_small_and_detail_owns_the_graph(self): assert outline_schema["required"] == ["candidates"] outline = outline_schema["properties"]["candidates"]["items"] - assert outline["required"] == ["candidate_name", "summary", "total_monthly_cost", "key_tradeoff"] + assert outline["required"] == ["candidate_name", "summary", "total_cost", "key_tradeoff"] assert "nodes" not in outline["properties"] graph = detail_schema["properties"]["topology_graph"] assert graph["required"] == ["nodes", "edges"] @@ -126,13 +126,13 @@ async def test_outline_call_emits_one_refining_card_per_candidate(self): { "candidate_name": "轻量方案", "summary": "单机", - "total_monthly_cost": "¥100~¥200/月", + "total_cost": "¥100~¥200/月", "key_tradeoff": "单点", }, { "candidate_name": "高可用方案", "summary": "双机", - "total_monthly_cost": "¥300~¥500/月", + "total_cost": "¥300~¥500/月", "key_tradeoff": "成本较高", }, ] @@ -155,7 +155,7 @@ async def test_identical_outline_batch_is_idempotent_and_emits_no_duplicate_card { "candidate_name": "轻量方案", "summary": "单机", - "total_monthly_cost": "¥100~¥200/月", + "total_cost": "¥100~¥200/月", "key_tradeoff": "单点", } ] @@ -195,7 +195,7 @@ async def test_changed_outline_batch_still_starts_a_new_candidate_set(self): { "candidate_name": "原方案", "summary": "单机", - "total_monthly_cost": "¥100/月", + "total_cost": "¥100/月", "key_tradeoff": "单点", } ] @@ -213,13 +213,13 @@ async def test_changed_outline_batch_still_starts_a_new_candidate_set(self): { "candidate_name": "原方案", "summary": "单机", - "total_monthly_cost": "¥100/月", + "total_cost": "¥100/月", "key_tradeoff": "单点", }, { "candidate_name": "新增方案", "summary": "轻量服务器", - "total_monthly_cost": "¥200/月", + "total_cost": "¥200/月", "key_tradeoff": "规格受限", }, ] @@ -241,7 +241,7 @@ async def test_outline_accepts_the_supported_batch_sizes(self, count): { "candidate_name": f"方案 {index}", "summary": f"摘要 {index}", - "total_monthly_cost": f"¥{index + 1}00/月", + "total_cost": f"¥{index + 1}00/月", "key_tradeoff": f"取舍 {index}", } for index in range(count) @@ -263,13 +263,13 @@ async def test_outline_accepts_the_supported_batch_sizes(self, count): { "candidate_name": "重复", "summary": "a", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "x", }, { "candidate_name": "重复", "summary": "b", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "y", }, ], @@ -277,7 +277,7 @@ async def test_outline_accepts_the_supported_batch_sizes(self, count): { "candidate_name": "方案", "summary": " ", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "x", } ], @@ -285,7 +285,7 @@ async def test_outline_accepts_the_supported_batch_sizes(self, count): { "candidate_name": f"方案 {index}", "summary": "摘要", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "取舍", } for index in range(4) @@ -325,13 +325,13 @@ async def test_detail_must_follow_current_batch_index_and_name(self): { "candidate_name": "方案 A", "summary": "A", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "A 取舍", }, { "candidate_name": "方案 B", "summary": "B", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "B 取舍", }, ] @@ -365,7 +365,7 @@ async def test_new_outline_batch_invalidates_old_successful_details(self): { "candidate_name": "旧方案", "summary": "旧", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "旧取舍", } ] @@ -390,7 +390,7 @@ async def test_new_outline_batch_invalidates_old_successful_details(self): { "candidate_name": "新方案", "summary": "新", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "新取舍", } ] @@ -423,13 +423,13 @@ async def test_restored_partial_batch_continues_with_only_the_first_missing_cand { "candidate_name": "方案 A", "summary": "A", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "A 取舍", }, { "candidate_name": "方案 B", "summary": "B", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "B 取舍", }, ] @@ -471,7 +471,7 @@ async def test_detail_from_an_explicit_old_batch_does_not_satisfy_the_new_batch( { "candidate_name": "同名方案", "summary": "旧摘要", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "旧取舍", } ] @@ -488,7 +488,7 @@ async def test_detail_from_an_explicit_old_batch_does_not_satisfy_the_new_batch( { "candidate_name": "同名方案", "summary": "新摘要", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "新取舍", } ] @@ -854,7 +854,7 @@ async def test_missing_event_queue_still_succeeds(self): { "candidate_name": "方案A:经典三层", "summary": "经典三层架构", - "total_monthly_cost": "¥200~¥400/月", + "total_cost": "¥200~¥400/月", "key_tradeoff": "组件较多", } ] @@ -877,7 +877,7 @@ async def test_same_outline_details_can_be_corrected_after_completion_guard_reje from iac_code.pipeline.engine.complete_step_tool import CompletionEnrichmentError from iac_code.pipeline.selling_solution_first.hooks.solution_planning_and_selection import enrich_completion_input - outline = {'candidate_name': '方案 A', 'summary': 'A', 'total_monthly_cost': '¥1/月', 'key_tradeoff': 'A'} + outline = {'candidate_name': '方案 A', 'summary': 'A', 'total_cost': '¥1/月', 'key_tradeoff': 'A'} detail = _tool_input(candidate_name='方案 A') records = [ {'tool_name': 'show_architecture_plan', 'input': {'candidates': [outline]}, 'is_error': False, @@ -902,7 +902,7 @@ def complete(): @pytest.mark.asyncio @pytest.mark.parametrize(('index', 'name'), [(1, '方案 A'), (True, '方案 A'), (-1, '方案 A'), (0, '旧方案')]) async def test_detail_correction_still_requires_actual_outline_index_and_name(index, name): - outline = {'candidate_name': '方案 A', 'summary': 'A', 'total_monthly_cost': '¥1/月', 'key_tradeoff': 'A'} + outline = {'candidate_name': '方案 A', 'summary': 'A', 'total_cost': '¥1/月', 'key_tradeoff': 'A'} state = {'tool_result_records': [ {'tool_name': 'show_architecture_plan', 'input': {'candidates': [outline]}, 'is_error': False, 'sequence': 1}, {'tool_name': 'show_candidate_detail', 'input': _tool_input(candidate_name='方案 A'), diff --git a/tests/pipeline/selling_solution_first/test_solution_planning_step.py b/tests/pipeline/selling_solution_first/test_solution_planning_step.py index 59f7cc873..503c47b00 100644 --- a/tests/pipeline/selling_solution_first/test_solution_planning_step.py +++ b/tests/pipeline/selling_solution_first/test_solution_planning_step.py @@ -45,14 +45,14 @@ def test_optional_candidate_resources_never_erase_explicit_user_or_non_create_re "name": "方案A:单机经济型", "summary": "单台 ECS 自建 Nginx + 本地 MySQL", "output_path": "templates/1-single-ecs.yml", - "rough_cost": {"currency": "CNY", "monthly_range": "¥120 - ¥180", "confidence": "medium"}, + "rough_cost": {"currency": "CNY", "cost_range": "¥120 - ¥180", "confidence": "medium"}, }, { "candidate_id": "cand-ha", "name": "方案B:高可用三层", "summary": "SLB + 2 台 ECS + RDS 高可用版", "output_path": "templates/2-high-availability-slb.yml", - "rough_cost": {"currency": "CNY", "monthly_range": "¥1,100 - ¥1,500", "confidence": "medium"}, + "rough_cost": {"currency": "CNY", "cost_range": "¥1,100 - ¥1,500", "confidence": "medium"}, }, ] OPTIONS = [ @@ -794,7 +794,7 @@ def test_every_candidate_carries_inventory_graph_and_rough_cost(self, raw_step): rough_cost = candidate["properties"]["rough_cost"] assert set(rough_cost["required"]) == { "currency", - "monthly_range", + "cost_range", "items", "assumptions", "exclusions", @@ -859,7 +859,7 @@ def test_skill_uses_one_candidate_coordinate_for_both_display_tools(self, skill_ assert "`options[i].candidate_index == i`" in skill_text assert "`candidate_id`、`output_path`" in skill_text assert "由 Python" in skill_text - assert "`rough_cost.monthly_range`" in skill_text + assert "`rough_cost.cost_range`" in skill_text # decision_notes 现在是完整的说服力字段(见「方案说服力」),仍要支撑 Python 生成紧凑 options。 assert "Python 生成紧凑 options" in skill_text diff --git a/tests/scripts/test_ci_run_e2e.py b/tests/scripts/test_ci_run_e2e.py index dbd8d3c01..7cb5960d2 100644 --- a/tests/scripts/test_ci_run_e2e.py +++ b/tests/scripts/test_ci_run_e2e.py @@ -167,8 +167,8 @@ def test_catalog_includes_headless_surfaces_and_excludes_browser_desktop() -> No full = run_e2e.select_cases(run_e2e.parse_args(["--suite", "full", "--list"])) live = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live", "--list"])) assert len(full) == 66 - assert len(live) == 130 - assert len(run_e2e.CASES) == 196 + assert len(live) == 132 + assert len(run_e2e.CASES) == 198 agui = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live-agui", "--list"])) assert {case.name for case in agui} == {"agui-selector-" + name for name in run_e2e.AGUI_SCENARIOS} assert len(agui) == 6 diff --git a/tests/scripts/test_coupon_budget_a2a.py b/tests/scripts/test_coupon_budget_a2a.py new file mode 100644 index 000000000..8df24c320 --- /dev/null +++ b/tests/scripts/test_coupon_budget_a2a.py @@ -0,0 +1,16 @@ +"""Acceptance distinguishes real period mistakes from free resources and prose.""" + +import pytest + +from scripts.ci.run_coupon_budget_a2a import hourly_costs_match + + +@pytest.mark.parametrize(("outlines", "details", "expected"), [ + (["¥30~¥60(本次 8 小时使用合计,非月费)"], ["¥10/8 小时", "¥0(VPC 本身免费)"], True), + (["¥24~¥40/8 小时"], ["¥3~¥5/小时", "¥0", "0", "免费"], True), + (["¥300/月(小时费用待确认)"], ["¥3/小时"], False), + (["¥3/小时"], ["¥300/月"], False), + ([], ["¥3/小时"], False), +]) +def test_hourly_estimate_acceptance(outlines, details, expected): + assert hourly_costs_match(outlines, details) is expected diff --git a/tests/scripts/test_coupon_fixtures.py b/tests/scripts/test_coupon_fixtures.py new file mode 100644 index 000000000..477276f23 --- /dev/null +++ b/tests/scripts/test_coupon_fixtures.py @@ -0,0 +1,66 @@ +"""Keep mock identity/pagination and answer acceptance from hiding model errors.""" + +import pytest + +from scripts.ci.coupon_fixtures import coupon, response +from scripts.ci.run_coupon_budget_mock_a2a import amount_matches, cash_cell, evaluate_answer + + +def test_an_old_coupon_id_cannot_observe_a_new_fixture(): + old = coupon("applicable")["CouponId"] + result = response("balance_60", "DescribeCouponItemList", {"CouponId": old}) + assert result["Data"] == [] + + +def test_pagination_does_not_repeat_the_coupon_on_later_pages(): + first = response("applicable", "DescribeCoupon", {"CurrentPage": 1, "PageSize": 1}) + second = response("applicable", "DescribeCoupon", {"CurrentPage": 2, "PageSize": 1}) + assert len(first["Data"]) == 1 + assert second["Data"] == [] + assert first["TotalCount"] == second["TotalCount"] == 1 + + +def test_answer_acceptance_ignores_the_face_value_and_estimate_rows(): + answer = "| 券面值 | 300 元 |\n| GPU计算费 | 240 元 |\n| 计算部分需补现金 | **180.00 元** |" + assert cash_cell(answer) == "**180.00 元**" + assert amount_matches(cash_cell(answer), 180) + assert not amount_matches(cash_cell(answer), 300) + + +def test_horizontal_answer_table_is_not_mistaken_for_a_missing_cash_value(): + answer = "| GPU计算费 | 本次计算费可抵扣金额 | 计算部分需补现金 |\n|---|---|---|\n|240 元|240 元|0 元|" + assert cash_cell(answer) == "0 元" + assert all(evaluate_answer("applicable", answer).values()) + + +def test_an_explanation_mentioning_cash_is_not_a_horizontal_header(): + answer = ( + "| 项目 | 结果 |\n|---|---|\n" + "| 券的可用余额 | 300 元(满减券,非现金抵扣余额) |\n" + "| 本次计算费可抵扣金额 | 0 元(未达门槛) |\n" + "| 计算部分需补现金 | 240 元 |" + ) + assert cash_cell(answer) == "240 元" + assert all(evaluate_answer("threshold", answer).values()) + + +def test_a_known_zero_deduction_is_distinct_from_uncertain_coupon_eligibility(): + estimate = "|本次计算费可抵扣金额|¥0|\n|计算部分需补现金|待确认 → 预计 ¥240|" + uncertain = "|本次计算费可抵扣金额|待确认|\n|计算部分需补现金|待确认|" + assert all(evaluate_answer("gpu_excluded", estimate).values()) + assert not all(evaluate_answer("expired", uncertain).values()) + + +@pytest.mark.parametrize(("cell", "expected", "matches"), [ + ("¥0.00(仅计算费用,实际结算为准)", 0, True), + ("180 元", 180, True), + ("0 元(待确认项:无,判定明确)", 0, True), + ("0 元(待确认项:无,判定明确)", None, False), + ("0 元(待确认金额)", 0, False), + ("待确认(如果可用则 0 元)", 0, False), + ("待确认(如果可用则 0 元)", None, True), + ("¥0.00", None, False), + ("", None, False), +]) +def test_cash_acceptance_preserves_unknowns(cell, expected, matches): + assert amount_matches(cell, expected) is matches diff --git a/tests/test_setup_packaging.py b/tests/test_setup_packaging.py index 3c34bf741..d84fad430 100644 --- a/tests/test_setup_packaging.py +++ b/tests/test_setup_packaging.py @@ -107,7 +107,13 @@ def _assert_expanded_solution_first_resources(package_root: Path, setup_module) @pytest.mark.asyncio -async def test_installed_step_one_can_read_swas_guide_from_an_unrelated_workspace(tmp_path, monkeypatch): +@pytest.mark.parametrize(("reference", "expected"), [ + ("cloud-products/swas.md", ["OriginPrice", "包月价格"]), + ("coupons.md", ["DescribeCoupon", "DescribeCouponItemList"]), +]) +async def test_installed_step_one_can_read_guides_from_an_unrelated_workspace( + tmp_path, monkeypatch, reference, expected, +): setup_module = _load_setup_module(monkeypatch) package_root = tmp_path / "installed" / "iac_code" _seed_solution_first_skill_files(package_root) @@ -116,7 +122,7 @@ async def test_installed_step_one_can_read_swas_guide_from_an_unrelated_workspac workspace = tmp_path / "workspace" workspace.mkdir() roots = [str(skill_root)] - tool_input = {"path": "references/cloud-products/swas.md"} + tool_input = {"path": f"references/{reference}"} tool = ReadFileTool() permission = await tool.check_permissions( @@ -129,8 +135,7 @@ async def test_installed_step_one_can_read_swas_guide_from_an_unrelated_workspac context=ToolContext(cwd=str(workspace), trusted_read_directories=roots, relative_read_directories=roots), ) assert not result.is_error - assert "OriginPrice" in result.content - assert "包月价格" in result.content + assert all(value in result.content for value in expected) def test_selling_skill_references_are_expanded_for_installed_artifacts(monkeypatch, tmp_path): diff --git a/tests/web/test_diagram_preview.py b/tests/web/test_diagram_preview.py index 984ece221..ce3935e25 100644 --- a/tests/web/test_diagram_preview.py +++ b/tests/web/test_diagram_preview.py @@ -38,7 +38,7 @@ def test_diagram_preview_page_uses_saved_theme_renderer_and_token_mode(tmp_path, assert '' in response.text assert 'data-architecture-diagram-renderer="mermaid"' in response.text assert 'data-token-mode="true"' in response.text - assert "/static/js/diagram_preview.js?v=diagram-preview-v7" in response.text + assert "/static/js/diagram_preview.js?v=diagram-preview-v8" in response.text def test_diagram_preview_serves_the_shared_30_template_corpus(tmp_path, monkeypatch) -> None: diff --git a/tests/web/test_frontend_static.py b/tests/web/test_frontend_static.py index 220158f88..7d0e7bb7b 100644 --- a/tests/web/test_frontend_static.py +++ b/tests/web/test_frontend_static.py @@ -1932,7 +1932,7 @@ def test_static_asset_versions_reload_rename_api_changes() -> None: workspace_source = _source(WORKSPACE_JS) assert "/static/styles.css?v=web-repl-ui-324" in html - assert "/static/js/app.js?v=web-repl-ui-382" in html + assert "/static/js/app.js?v=web-repl-ui-383" in html # api.js 导出 WEB_EVENT_TYPES(EventSource 订阅白名单)与 openEventStream;新增 # pipeline.step.marker 订阅后必须 bump 其 import 版本位,否则回访浏览器加载「新 # app.js + 旧缓存 api.js」,EventSource 仍不监听该事件名,实时流水线主区照样空白。 @@ -1966,7 +1966,7 @@ def test_static_asset_versions_reload_rename_api_changes() -> None: # cloud-creds 面板(Task 5/6)重写后须 bump 全局版本位并给 workspace.js 加 per-file # 版本位,否则回访浏览器加载旧缓存 workspace.js,拿不到新的云凭证面板结构。 - assert "web-repl-ui-382" in index_html + assert "web-repl-ui-383" in index_html assert "web-repl-ui-333" not in index_html # events.js 新增实时 MCP/工具进度归并,必须 bump 版本避免旧 reducer 丢事件。 assert "./events.js?v=web-repl-ui-324" in app_source @@ -11724,7 +11724,7 @@ def test_session_updated_folds_current_session_into_sidebar_arrays() -> None: def test_index_html_cache_version_bumped() -> None: html = _source(INDEX_HTML) - assert "web-repl-ui-382" in html + assert "web-repl-ui-383" in html assert "web-repl-ui-343" not in html @@ -12231,7 +12231,7 @@ def test_output_panel_module_exists_and_wired() -> None: assert "getOutputs" in source app_source = _source(APP_JS) assert "createOutputController" in app_source - assert "output_panel.js?v=output-panel-v38" in app_source + assert "output_panel.js?v=output-panel-v39" in app_source def test_desktop_resource_stack_links_use_native_external_opener() -> None: @@ -12315,7 +12315,7 @@ def test_output_preview_and_highlight() -> None: assert "File no longer exists" in source assert "tok-" in source app_source = _source(APP_JS) - assert "output_panel.js?v=output-panel-v38" in app_source + assert "output_panel.js?v=output-panel-v39" in app_source assert "output_panel.js?v=output-panel-v23" not in app_source @@ -12346,8 +12346,8 @@ def test_output_panel_uses_codex_dark_theme() -> None: def test_output_panel_renders_architecture_diagram_section() -> None: js = _source(OUTPUT_PANEL_JS) - assert 'from "../mermaid_render.js?v=arch-diagram-v5"' in js - assert 'from "../architecture_diagram.js?v=eraser-browser-v14"' in js + assert 'from "../mermaid_render.js?v=cost-period-v6"' in js + assert 'from "../architecture_diagram.js?v=eraser-browser-v15"' in js assert "renderDiagramRow" in js assert '"Architecture diagram"' in js # 计数把 diagrams 计入(驱动徽标/自动显隐/自动弹出) @@ -12498,8 +12498,8 @@ def test_standalone_diagram_preview_supports_template_library_picker_drop_and_sh assert 'data-diagram-preview="corpus-select"' in html assert 'data-diagram-preview="random"' in html assert "/static/diagram-preview.css?v=diagram-preview-v2" in html - assert "/static/js/diagram_preview.js?v=diagram-preview-v7" in html - assert 'from "./architecture_diagram.js?v=eraser-browser-v14"' in source + assert "/static/js/diagram_preview.js?v=diagram-preview-v8" in html + assert 'from "./architecture_diagram.js?v=eraser-browser-v15"' in source assert 'from "./diagram_preview_library.js?v=template-library-v1"' in source assert '"/static/diagram-preview-corpus.json?v=visual-corpus-v1"' in source assert 'tokenFetch("/api/diagram-preview"' in source @@ -12557,8 +12557,8 @@ def test_diagram_preview_library_indexes_repository_and_randomizes_only_other_te def test_pipeline_candidate_inline_diagram_uses_web_diagrams() -> None: js = _source(PIPELINE_JS) - assert 'from "../mermaid_render.js?v=arch-diagram-v5"' in js - assert 'from "../architecture_diagram.js?v=eraser-browser-v14"' in js + assert 'from "../mermaid_render.js?v=cost-period-v6"' in js + assert 'from "../architecture_diagram.js?v=eraser-browser-v15"' in js assert "webDiagrams" in js # combinedDiagrams 合并 state.webDiagrams assert "pipeline-candidate-diagram" in js # 每卡可折叠架构图 assert "renderArchitectureDiagram" in js @@ -12571,7 +12571,7 @@ def test_pipeline_js_import_is_versioned() -> None: # pipeline.js 之前是 app.js 里唯一无版本位的 import;内容改动(含本轮 index 优先 # 匹配修复)在回访浏览器的 warm cache 下不会重新拉取。加版本位以确保修复落地。 app_source = _source(APP_JS) - assert "./components/pipeline.js?v=pipeline-solution-confirm-v16" in app_source + assert "./components/pipeline.js?v=pipeline-solution-confirm-v17" in app_source def test_solution_first_deployment_confirmation_uses_compact_actual_options() -> None: @@ -13158,7 +13158,7 @@ def test_app_regroups_pipeline_messages_before_render() -> None: def test_app_output_panel_import_bumped_for_desktop_external_links() -> None: js = _source(APP_JS) - assert "output-panel-v38" in js + assert "output-panel-v39" in js assert "output-panel-v24" not in js @@ -13325,11 +13325,12 @@ def _run_mermaid_render_script(tmp_path, source): return out.stdout -def test_render_diagram_price_with_cost(tmp_path): +@pytest.mark.parametrize("estimate", ["¥120/月", "¥3~¥5/小时(8小时共¥24~¥40)", "¥1200/年"]) +def test_render_diagram_price_with_cost(tmp_path, estimate): out = _run_mermaid_render_script( tmp_path, "const { renderDiagramPrice } = await import('__MERMAID_MODULE__');\n" - "const el = renderDiagramPrice({ totalMonthlyCost: '¥120/月'," + f"const el = renderDiagramPrice({{ totalMonthlyCost: {json.dumps(estimate)}," " costItems: [{ name: 'ECS', spec: '2c4g', monthly_cost: '¥100' }," " { name: 'RDS', monthly_cost: '¥20' }] });\n" "function findClass(n, cls){ if((n.className||'')===cls) return n;" @@ -13344,7 +13345,9 @@ def test_render_diagram_price_with_cost(tmp_path): ) data = json.loads(out.strip().splitlines()[-1]) assert data["hasTotal"] is True - assert "120" in data["totalText"] + assert estimate in data["totalText"] + assert "Estimated cost" in data["totalText"] + assert "Estimated monthly cost" not in data["totalText"] assert data["liCount"] == 2 assert data["empty"] is False From 524bbdb5781034d8b52bf33dc80cd1fa6eb9c00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 22 Oct 2026 21:18:01 +0800 Subject: [PATCH 08/16] test(ci): match assistant references by parsed URL host Parse HTTPS URLs and compare the console hostname exactly when recording assistant references in coupon fixture reports. Cover legitimate links, forged domains, path/query lookalikes, userinfo and malformed URLs. --- scripts/ci/run_coupon_budget_mock_a2a.py | 15 ++++++++++++++- tests/scripts/test_coupon_fixtures.py | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_coupon_budget_mock_a2a.py b/scripts/ci/run_coupon_budget_mock_a2a.py index 3ab82dcd5..e59ec7132 100644 --- a/scripts/ci/run_coupon_budget_mock_a2a.py +++ b/scripts/ci/run_coupon_budget_mock_a2a.py @@ -16,6 +16,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from urllib.parse import urlsplit REPO = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO)) @@ -52,6 +53,18 @@ ) +def has_assistant_reference(text: str) -> bool: + """Recognize console links by their parsed host, not a substring in an arbitrary URL.""" + for url in re.findall(r"https?://[^\s<>()\[\]]+", text): + try: + parsed = urlsplit(url) + if parsed.scheme == "https" and parsed.hostname == "ros.console.aliyun.com": + return True + except ValueError: + continue + return False + + def answer_cell(text: str, label: str) -> str: """Accept vertical or horizontal Markdown tables without using unrelated amounts.""" rows = [ @@ -200,7 +213,7 @@ def run(args: argparse.Namespace) -> dict[str, Any]: row = { "case": case, "title": CASES[case].title, "expected_cash": CASES[case].cash, "cash_cell": cell, "passed": all(local.values()), "checks": local, - "assistant_reference_present": "ros.console.aliyun.com/" in text, + "assistant_reference_present": has_assistant_reference(text), "seconds": round(time.monotonic() - start, 2), } rows.append(row) diff --git a/tests/scripts/test_coupon_fixtures.py b/tests/scripts/test_coupon_fixtures.py index 477276f23..d42e5302b 100644 --- a/tests/scripts/test_coupon_fixtures.py +++ b/tests/scripts/test_coupon_fixtures.py @@ -3,7 +3,21 @@ import pytest from scripts.ci.coupon_fixtures import coupon, response -from scripts.ci.run_coupon_budget_mock_a2a import amount_matches, cash_cell, evaluate_answer +from scripts.ci.run_coupon_budget_mock_a2a import amount_matches, cash_cell, evaluate_answer, has_assistant_reference + + +@pytest.mark.parametrize(("text", "expected"), [ + ("[AI Assistant](https://ros.console.aliyun.com/cn-beijing/chat)", True), + ("https://ROS.CONSOLE.ALIYUN.COM/cn-hangzhou/chat", True), + ("https://example.com/ros.console.aliyun.com/cn-beijing/chat", False), + ("https://ros.console.aliyun.com.example.com/cn-beijing/chat", False), + ("https://ros.console.aliyun.com@example.com/cn-beijing/chat", False), + ("https://example.com/?next=https%3A%2F%2Fros.console.aliyun.com%2Fchat", False), + ("https://[invalid/path", False), + ("No link", False), +]) +def test_assistant_reference_uses_the_exact_url_host(text, expected): + assert has_assistant_reference(text) is expected def test_an_old_coupon_id_cannot_observe_a_new_fixture(): From 515e681773e319b1d0f7981cd369be6b22286b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 22 Oct 2026 21:25:12 +0800 Subject: [PATCH 09/16] fix(ci): sync cost translations and test file encodings Translate period-neutral candidate cost descriptions in all six locales and explicitly use UTF-8 for candidate recovery and cost-contract fixtures. --- .../i18n/locales/de/LC_MESSAGES/messages.po | 22 +++++++++++-------- .../i18n/locales/es/LC_MESSAGES/messages.po | 21 +++++++++++------- .../i18n/locales/fr/LC_MESSAGES/messages.po | 18 +++++++++------ .../i18n/locales/ja/LC_MESSAGES/messages.po | 16 ++++++++------ .../i18n/locales/pt/LC_MESSAGES/messages.po | 22 +++++++++++-------- .../i18n/locales/zh/LC_MESSAGES/messages.po | 16 ++++++++------ .../test_candidate_batch_e2e.py | 4 ++-- .../test_cost_field_compatibility.py | 6 ++++- 8 files changed, 75 insertions(+), 50 deletions(-) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 126005681..33c62b865 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -6158,14 +6158,14 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Zeigen Sie vor der Erzeugung ausführlicher Details einen vollständigen " "Satz kompakter Kandidatenübersichten an. Übermitteln Sie jeden aktuellen " -"Kandidaten der Reihe nach mit Name, Zusammenfassung, monatlicher " -"Schätzung und wichtigster Abwägung. Fügen Sie keine Topologieknoten, " -"Ressourcenbestände oder detaillierten Kostenpositionen hinzu." +"Kandidaten der Reihe nach mit Name, Zusammenfassung, Kostenschätzung und " +"wichtigster Abwägung. Fügen Sie keine Topologieknoten, Ressourcenbestände" +" oder detaillierten Kostenpositionen hinzu." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6184,8 +6184,12 @@ msgid "Short product combination and architecture summary" msgstr "Kurze Zusammenfassung der Produktkombination und Architektur" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Ungefähre monatliche Spanne, z. B. ¥230~¥380/Monat" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Ungefähre Kostenspanne mit Zeitraum: standardmäßig monatlich oder in der " +"vom Benutzer gewünschten Zeiteinheit und Dauer." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6296,14 +6300,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Zeigen Sie die vollständigen Details für genau einen Kandidaten aus dem " "neuesten show_architecture_plan-Satz. Rufen Sie die Funktion einmal pro " "Modellrunde in der Reihenfolge der Kandidatenindizes auf. Geben Sie " "Ressourcenlebenszyklus, Topologiegraf, Ressourcenbestand, Kostenannahmen " "und Entscheidungshinweise an; wiederholen Sie weder Zusammenfassung noch " -"Monatssumme." +"Gesamtkosten." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 8e6a10f34..9977637a2 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -6113,13 +6113,14 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Muestra un lote completo de resúmenes ligeros de candidatos antes de " "generar los detalles. Envía cada candidato actual en orden con su nombre," -" resumen, estimación mensual y principal contrapartida. No incluyas nodos" -" de topología, inventario de recursos ni partidas de coste detalladas." +" resumen, estimación de costes y principal contrapartida. No incluyas " +"nodos de topología, inventario de recursos ni partidas de coste " +"detalladas." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6138,8 +6139,12 @@ msgid "Short product combination and architecture summary" msgstr "Resumen breve de la combinación de productos y la arquitectura" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Intervalo mensual aproximado, por ejemplo ¥230~¥380/mes" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Intervalo de costes aproximado con su período: mensual por defecto, o " +"según la unidad y la duración solicitadas por el usuario." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6247,14 +6252,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Muestra los detalles completos de exactamente un candidato del último " "lote de show_architecture_plan. Llama una vez por turno del modelo " "siguiendo el orden del índice de candidatos. Incluye la intención del " "ciclo de vida de los recursos, el grafo de topología, el inventario de " "recursos, los supuestos de coste y las notas de decisión; no repitas el " -"resumen ni el total mensual." +"resumen ni el coste total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 1fb41efbc..efaebf5d5 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -6120,12 +6120,12 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Affichez un lot complet de résumés légers des candidats avant de générer " "les détails. Soumettez chaque candidat actuel dans l’ordre avec son nom, " -"son résumé, son estimation mensuelle et son principal compromis. " +"son résumé, son estimation des coûts et son principal compromis. " "N’incluez ni nœuds de topologie, ni inventaire de ressources, ni postes " "de coût détaillés." @@ -6146,8 +6146,12 @@ msgid "Short product combination and architecture summary" msgstr "Bref résumé de la combinaison de produits et de l’architecture" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Fourchette mensuelle approximative, par exemple ¥230~¥380/mois" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Fourchette de coûts approximative avec sa période : mensuelle par défaut," +" ou selon l’unité et la durée demandées par l’utilisateur." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6260,14 +6264,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Affichez les détails complets d’un seul candidat du dernier lot " "show_architecture_plan. Effectuez un appel par tour de modèle dans " "l’ordre des index de candidats. Incluez l’intention de cycle de vie des " "ressources, le graphe de topologie, l’inventaire des ressources, les " "hypothèses de coût et les notes de décision ; ne répétez ni le résumé ni " -"le total mensuel." +"le coût total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index bdb41a037..10505ae40 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -5753,9 +5753,9 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." -msgstr "詳細情報を生成する前に、軽量な候補概要を完全な1バッチとして表示します。現在の各候補を順番に、名前、概要、月額見積もり、主なトレードオフとともに送信してください。トポロジーノード、リソース一覧、詳細な費用項目は含めないでください。" +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." +msgstr "詳細情報を生成する前に、軽量な候補概要を完全な1バッチとして表示します。現在の各候補を順番に、名前、概要、費用見積もり、主なトレードオフとともに送信してください。トポロジーノード、リソース一覧、詳細な費用項目は含めないでください。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -5772,8 +5772,10 @@ msgid "Short product combination and architecture summary" msgstr "製品構成とアーキテクチャの簡潔な概要" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "月額のおおよその範囲(例: ¥230~¥380/月)" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "期間を含む概算費用の範囲。既定では月額とし、ユーザーが時間単位と利用期間を指定した場合はそれに従います。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -5878,10 +5880,10 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "最新の show_architecture_plan " -"バッチから候補を1つだけ選び、その詳細を表示します。モデルの各ターンで候補インデックス順に1回ずつ呼び出してください。リソースのライフサイクル意図、トポロジーグラフ、リソース一覧、費用の前提、判断メモを含め、概要や月額合計は繰り返さないでください。" +"バッチから候補を1つだけ選び、その詳細を表示します。モデルの各ターンで候補インデックス順に1回ずつ呼び出してください。リソースのライフサイクル意図、トポロジーグラフ、リソース一覧、費用の前提、判断メモを含め、概要や費用合計は繰り返さないでください。" #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 3cd2685b3..a9de26615 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -6064,13 +6064,13 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Exiba um lote completo de resumos leves dos candidatos antes de gerar os " "detalhes. Envie cada candidato atual em ordem, com nome, resumo, " -"estimativa mensal e principal contrapartida. Não inclua nós de topologia," -" inventário de recursos nem itens de custo detalhados." +"estimativa de custo e principal contrapartida. Não inclua nós de " +"topologia, inventário de recursos nem itens de custo detalhados." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6089,8 +6089,12 @@ msgid "Short product combination and architecture summary" msgstr "Resumo breve da combinação de produtos e da arquitetura" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Faixa mensal aproximada, por exemplo ¥230~¥380/mês" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Faixa de custo aproximada com seu período: mensal por padrão, ou conforme" +" a unidade e a duração solicitadas pelo usuário." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6199,14 +6203,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Exiba os detalhes completos de exatamente um candidato do lote mais " "recente de show_architecture_plan. Faça uma chamada por turno do modelo " "na ordem dos índices dos candidatos. Inclua a intenção do ciclo de vida " "dos recursos, o grafo de topologia, o inventário de recursos, as " -"premissas de custo e as notas de decisão; não repita o resumo nem o total" -" mensal." +"premissas de custo e as notas de decisão; não repita o resumo nem o custo" +" total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index e07242b9c..2ac6d6a56 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -5681,9 +5681,9 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." -msgstr "在生成详细信息前,先一次性展示一批完整的轻量候选方案概览。按顺序提交当前每个候选方案的名称、摘要、月度估价和关键取舍。不要包含拓扑节点、资源清单或详细费用项。" +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." +msgstr "在生成详细信息前,先一次性展示一批完整的轻量候选方案概览。按顺序提交当前每个候选方案的名称、摘要、费用估算和关键取舍。不要包含拓扑节点、资源清单或详细费用项。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -5700,8 +5700,10 @@ msgid "Short product combination and architecture summary" msgstr "简短的产品组合与架构摘要" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "粗略月费范围,例如 ¥230~¥380/月" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "包含计费周期的粗略费用范围:默认按月,或按用户要求的时间单位和时长。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -5805,10 +5807,10 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "仅展示最新 show_architecture_plan " -"批次中一个候选方案的完整详情。每个模型轮次按候选方案索引顺序调用一次。包括资源生命周期意图、拓扑图、资源清单、费用假设和决策说明;不要重复摘要或月度总价。" +"批次中一个候选方案的完整详情。每个模型轮次按候选方案索引顺序调用一次。包括资源生命周期意图、拓扑图、资源清单、费用假设和决策说明;不要重复摘要或总费用。" #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" diff --git a/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py b/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py index 9d775086d..2d5f9d321 100644 --- a/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py +++ b/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py @@ -163,7 +163,7 @@ def runner(*, resume=False): if mode == "legacy-cold-recovery": # Simulate a sidecar written by the previous version, keeping state/version metadata intact. context_path = flow.session.context_path - snapshot = yaml.safe_load(context_path.read_text()) + snapshot = yaml.safe_load(context_path.read_text(encoding="utf-8")) for candidate in snapshot["solution_selection"]["value"]["candidates"]: cost = candidate["rough_cost"] cost["monthly_range"] = cost.pop("cost_range") @@ -171,7 +171,7 @@ def runner(*, resume=False): item["monthly_cost"] = item.pop("cost") for resource in candidate["resource_inventory"]: resource["rough_monthly_cost"] = resource.pop("rough_cost") - context_path.write_text(yaml.safe_dump(snapshot, allow_unicode=True)) + context_path.write_text(yaml.safe_dump(snapshot, allow_unicode=True), encoding="utf-8") if mode != "same-process": # Construct a new runner/storage: restore the real sidecar and attempt # transcript from disk, without carrying the live completion guard. diff --git a/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py b/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py index 0b1012363..b96bb53dd 100644 --- a/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py +++ b/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py @@ -130,5 +130,9 @@ def test_all_current_model_contracts_and_skill_examples_avoid_legacy_cost_fields ShowArchitecturePlanTool().input_schema, ShowCandidateDetailTool().input_schema, pipeline.steps[0].conclusion_schema, ] - rendered = json.dumps(contracts) + (skill_root / "SKILL.md").read_text() + (skill_root / "evals.json").read_text() + rendered = ( + json.dumps(contracts) + + (skill_root / "SKILL.md").read_text(encoding="utf-8") + + (skill_root / "evals.json").read_text(encoding="utf-8") + ) assert not any(key in rendered for key in LEGACY_FIELDS) From 906779073404b58278282f207ea88d554a3176de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 22 Oct 2026 22:38:30 +0800 Subject: [PATCH 10/16] test(aliyun): isolate metadata backoff deadline from runner timing Simulate elapsed request time with a budget clock so the backoff boundary test reaches its first response on Windows under load. Preserve the single-attempt and wall-clock assertions, the real asyncio scheduler, and production timeout behavior. --- tests/tools/cloud/aliyun/test_openmeta_retry.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/tools/cloud/aliyun/test_openmeta_retry.py b/tests/tools/cloud/aliyun/test_openmeta_retry.py index 0d0382455..822e92047 100644 --- a/tests/tools/cloud/aliyun/test_openmeta_retry.py +++ b/tests/tools/cloud/aliyun/test_openmeta_retry.py @@ -6,7 +6,9 @@ import json import logging import time +from functools import partial from pathlib import Path +from types import SimpleNamespace import httpx import pytest @@ -15,7 +17,7 @@ from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi from iac_code.tools.cloud.aliyun.contract_store import canonical_input_sha256 from iac_code.tools.cloud.aliyun.openmeta import OpenMetaClient -from iac_code.tools.cloud.aliyun.retry_policy import retry_delay +from iac_code.tools.cloud.aliyun.retry_policy import RetryBudget, retry_delay from iac_code.tools.cloud.aliyun.runtime import create_aliyun_runtime_services from iac_code.types.permissions import InvocationBinding, ToolPermissionContext @@ -122,13 +124,21 @@ async def handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_backoff_cannot_cross_metadata_deadline(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(module, "_REQUEST_TIMEOUT_SECONDS", 0.01) + elapsed = 0.0 + # Leave request startup independent of runner load, then simulate a response + # with only 10 ms left. Keep asyncio's scheduler on its real clock. + def clock() -> float: + return elapsed + + monkeypatch.setattr(module, "time", SimpleNamespace(monotonic=clock)) + monkeypatch.setattr(module, "RetryBudget", partial(RetryBudget, clock=clock)) monkeypatch.setattr(module, "retry_delay", retry_delay) calls = 0 async def handler(_request: httpx.Request) -> httpx.Response: - nonlocal calls + nonlocal calls, elapsed calls += 1 + elapsed = module._REQUEST_TIMEOUT_SECONDS - 0.01 return httpx.Response(503, headers={"Retry-After": "2"}) client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) From ce14341a704948425c8c1918c252ce2f9c4fc073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Fri, 23 Oct 2026 10:52:20 +0800 Subject: [PATCH 11/16] fix(a2a): persist permission continuations and clarify consultation routing Project detached permission continuation events through TaskManager so GetTask exposes the final reply and clears consumed input. Accept complete fenced VPC templates from resolved final replies without accepting permission receipts. Place optional ROS consultation routing after general pipeline constraints, keep coupon budget work in the deployment flow, and clarify the billing scenario input without changing acceptance checks. --- scripts/a2a/smoke/test_a2a_vpc.py | 37 +++++++++----- scripts/ci/run_ros_agent_awareness.py | 2 +- src/iac_code/a2a/transports/dispatcher.py | 17 ++++++- .../solution_planning_and_selection.md | 1 + .../skills/iac-aliyun-solution-first/SKILL.md | 2 +- .../ros-agent-awareness.md | 2 +- tests/a2a/test_transport_dispatcher.py | 49 +++++++++++++++++++ .../test_ros_agent_awareness.py | 1 + tests/scripts/test_a2a_vpc_smoke.py | 28 +++++++++++ 9 files changed, 123 insertions(+), 16 deletions(-) diff --git a/scripts/a2a/smoke/test_a2a_vpc.py b/scripts/a2a/smoke/test_a2a_vpc.py index be3107f7e..15c8f78b7 100644 --- a/scripts/a2a/smoke/test_a2a_vpc.py +++ b/scripts/a2a/smoke/test_a2a_vpc.py @@ -17,6 +17,7 @@ import argparse import json import os +import re import socket import subprocess import sys @@ -342,27 +343,41 @@ def _normal_turn_completed(task: dict, *, timeout: float) -> bool: def _normal_turn_output(task: dict, cli_text: str, *, permission_submitted: bool) -> str: # A reply consumer may retain an old permission Task even after its owner - # finished. After the matching completion proof, accept only a whole ROS + # finished. After the matching completion proof, accept only a complete ROS # template from history or the just-finished CLI call, never a receipt. if not permission_submitted: # No reply/receipt call replaced the original sync output. Once that # task has released naturally, keep its own CLI reply for acceptance. return cli_text or _completed_task_text(task) + metadata = task.get('metadata') + iac = metadata.get('iac_code') if isinstance(metadata, dict) else None + input_resolved = not isinstance(iac, dict) or ( + iac.get('input') is None and not iac.get('pendingPermissions') + ) for text in (_completed_task_text(task), cli_text): wire = text.strip() lines = wire.splitlines() if len(lines) >= 3 and lines[0] in {'```', '```json'} and lines[-1] == '```': wire = '\n'.join(lines[1:-1]) - try: - value = json.loads(wire) - except ValueError: - continue - resources = value.get('Resources') if isinstance(value, dict) else None - if isinstance(resources, dict) and any( - isinstance(resource, dict) and resource.get('Type') == 'ALIYUN::ECS::VPC' - for resource in resources.values() - ): - return text + candidates = [wire] + if input_resolved: + # A final reply may introduce its fenced template with prose. Do + # not extract snippets from a still-pending permission prompt. + candidates.extend(re.findall( + r'^[ \t]*```(?:json)?[ \t]*\r?\n(.*?)\r?\n[ \t]*```[ \t]*$', + text, flags=re.MULTILINE | re.DOTALL, + )) + for candidate in candidates: + try: + value = json.loads(candidate) + except ValueError: + continue + resources = value.get('Resources') if isinstance(value, dict) else None + if isinstance(resources, dict) and any( + isinstance(resource, dict) and resource.get('Type') == 'ALIYUN::ECS::VPC' + for resource in resources.values() + ): + return text return '' diff --git a/scripts/ci/run_ros_agent_awareness.py b/scripts/ci/run_ros_agent_awareness.py index 8179d7b8a..1ce01721b 100644 --- a/scripts/ci/run_ros_agent_awareness.py +++ b/scripts/ci/run_ros_agent_awareness.py @@ -31,7 +31,7 @@ class Scenario: SCENARIOS = { "promotions": Scenario("阿里云现在有什么优惠活动和优惠券?我只想咨询,暂时不部署资源。"), "renewal": Scenario("已有的 ECS 快到期了,续费有折扣吗?"), - "billing": Scenario("按量付费的 ECS 是如何计费的?欠费后怎么办?"), + "billing": Scenario("我只咨询已有 ECS 的按量账单,不打算部署或创建资源。按量付费如何计费,欠费后怎么办?"), "gpu-availability": Scenario("北京地域的 GPU 实例现在有哪些库存和单价?暂时不部署。", region="cn-shanghai"), "traffic-offset": Scenario("有没有能抵扣已有 ECS 公网流量费的流量包?"), "default-region": Scenario("我只想咨询阿里云的续费优惠,暂时不创建资源。", region=None), diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index 299044be2..77046d0aa 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -17,6 +17,7 @@ from a2a.server.events.event_queue_v2 import QueueShutDown from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.routes import create_jsonrpc_routes +from a2a.server.tasks import TaskManager from a2a.server.tasks.inmemory_task_store import DEFAULT_LIST_TASKS_PAGE_SIZE, decode_page_token, encode_page_token from a2a.types import ( CancelTaskRequest, @@ -138,10 +139,15 @@ class _ASGIResponseChunk: class _DetachedPermissionEventQueue(EventQueue): """Minimal producer queue for resuming an already-persisted A2A task.""" - def __init__(self, queue: asyncio.Queue[Any]) -> None: + def __init__(self, queue: asyncio.Queue[Any], task_manager: TaskManager) -> None: self._queue = queue + self._task_manager = task_manager async def enqueue_event(self, event: Any) -> None: + # This direct continuation bypasses DefaultRequestHandler's consumer. + # Keep GetTask in sync even when a nonstream caller discards the events + # or the response disconnects after its decision was committed. + await self._task_manager.process(event) await self._queue.put(event) @@ -1014,7 +1020,14 @@ async def _on_inactive_input_response_send_stream(self, params: SendMessageReque async def run_permission_response() -> None: nonlocal request_context try: - event_queue = _DetachedPermissionEventQueue(queue) + task_manager = TaskManager( + task_id=task.id, + context_id=task.context_id, + task_store=self.task_store, + context=context, + initial_message=None, + ) + event_queue = _DetachedPermissionEventQueue(queue, task_manager) RecoverableInputAdmissionCarrier.acknowledge_enqueued(request_context, asyncio.current_task()) try: await self.agent_executor.execute(request_context, event_queue) diff --git a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md index dbd0b2fed..c9d8554f2 100644 --- a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md +++ b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md @@ -57,3 +57,4 @@ candidate_id、output_path 和同序 options。 - `complete_step` 参数必须是 `{"conclusion": {...}}`,不得把结论字段放到工具参数顶层。 - 本 Step 不生成或写入模板、不做 ROS 精确询价、不执行云写操作。只读查询、记忆读取和展示的边界遵循技能与当前工具权限。 +{ros_agent_awareness} diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md index ec072fe77..6a6f2aadb 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md @@ -39,7 +39,7 @@ user_invocable: false - **medium**:用户描述了业务目标,可合理推断需要基础设施(如"我想做个在线商城") - **low**:描述极其模糊,是否需要基础设施尚不确定(如"我有个项目想上线") -置信度写入 `intent.confidence`。{ros_agent_awareness} +置信度写入 `intent.confidence`。 ## 澄清提问能力 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md index 3fdab0121..84eabcf31 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md @@ -1 +1 @@ -ROS console override: Except deployment coupon applicability/budget (follow coupon guide), standalone billing, promotions/renewals, pricing/availability, traffic offsets and promotional-offer followups during selection first visibly link {ai_assistant_destination}; `complete_step` with `status: rejected` and linked reply in `rejection_reason`; override clarification/reselection. Questions are not selections. Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. +ROS console override: Deployment coupon/budget stays here even if no coupon applies. For standalone billing, promos/renewals, prices/availability, traffic offsets or selection promos, first OUTPUT {ai_assistant_destination} as visible text, NOT only tool input; then only `complete_step` with `status: rejected`, full URL in `rejection_reason`; override clarification/reselection. Questions are not selections. Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. diff --git a/tests/a2a/test_transport_dispatcher.py b/tests/a2a/test_transport_dispatcher.py index 2f885e3ff..4bf1f8fff 100644 --- a/tests/a2a/test_transport_dispatcher.py +++ b/tests/a2a/test_transport_dispatcher.py @@ -2578,6 +2578,53 @@ async def reconcile(_params, _context) -> None: assert finalized is False +@pytest.mark.asyncio +async def test_inactive_input_response_projects_final_reply_and_consumed_permission() -> None: + call_context = ServerCallContext() + task = Task( + id="task-reply", context_id="ctx-reply", + status=TaskStatus(state=TaskState.TASK_STATE_INPUT_REQUIRED, + message=Message(role=Role.ROLE_AGENT, parts=[Part(text="permission needed")])), + ) + ParseDict({"iac_code": {"input": {"kind": "permission"}}}, task.metadata) + store = A2ATaskStore() + await store.save(task, call_context) + + class RequestContextBuilder: + async def build(self, **_kwargs): + return SimpleNamespace(call_context=call_context) + + class Executor: + async def execute(self, _request_context, event_queue): + final = TaskStatusUpdateEvent( + task_id=task.id, context_id=task.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_WORKING, + message=Message(role=Role.ROLE_AGENT, parts=[Part(text="final reply")])), + ) + ParseDict({"iac_code": {"assistantFinal": {"complete": True}}}, final.metadata) + await event_queue.enqueue_event(final) + await event_queue.enqueue_event(TaskStatusUpdateEvent( + task_id=task.id, context_id=task.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_INPUT_REQUIRED), + )) + + handler = IacCodeRequestHandler.__new__(IacCodeRequestHandler) + handler.task_store = store + handler.agent_executor = Executor() + handler._request_context_builder = RequestContextBuilder() + handler._detached_message_producers = set() + params = SimpleNamespace(message=SimpleNamespace(task_id=task.id, context_id=task.context_id), + configuration=None) + + events = await _collect_async(handler._on_inactive_input_response_send_stream(params, call_context, task=task)) + + projected = await store.get(task.id, call_context) + assert projected is not None and len(events) == 2 + assert projected.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + assert any(part.text == "final reply" for message in projected.history for part in message.parts) + assert "input" not in dict(projected.metadata["iac_code"]) + + @pytest.mark.asyncio async def test_detached_permission_response_finalizes_after_terminal_producer_drains() -> None: call_context = ServerCallContext() @@ -2627,6 +2674,8 @@ async def finalize_natural_execution(self, **kwargs) -> dict: await asyncio.wait_for(finalized.wait(), timeout=1) await asyncio.gather(*handler._detached_message_producers) + projected = await handler.task_store.get(task.id, call_context) + assert projected is not None and projected.status.state == TaskState.TASK_STATE_COMPLETED @pytest.mark.asyncio diff --git a/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py b/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py index beb76b72c..07229f29e 100644 --- a/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py +++ b/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py @@ -132,6 +132,7 @@ def test_handoff_is_limited_to_planning_and_leaves_normal_prompts_alone(executor context = PipelineContext(executor._pipeline.context_dependencies) prompts = [executor._build_full_system_prompt(step, context) for step in executor._pipeline.steps] assert "ROS console override" in prompts[0] + assert prompts[0].index("ROS console override") > prompts[0].index("## Pipeline 交接约束") assert all("ROS console override" not in prompt for prompt in prompts[1:]) assert "ROS console override" not in build_system_prompt(cwd=str(tmp_path)) assert "Keep deployment architecture/cost tradeoffs here" in prompts[0] diff --git a/tests/scripts/test_a2a_vpc_smoke.py b/tests/scripts/test_a2a_vpc_smoke.py index 28a46bb73..f568e64ca 100644 --- a/tests/scripts/test_a2a_vpc_smoke.py +++ b/tests/scripts/test_a2a_vpc_smoke.py @@ -236,6 +236,7 @@ def test_normal_turn_without_final_metadata_requires_natural_completion_proof(mo @pytest.mark.parametrize('cli_text, passed', [ ('{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}', True), ('```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```', True), + ('Template:\n```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```', False), ('VPC permission accepted', False), ('{"kind":"permission_ack","target":"vpc.json"}', False), ]) def test_finished_owner_with_stale_permission_requires_template_not_receipt(monkeypatch, tmp_path, cli_text, passed): @@ -253,6 +254,33 @@ def cli(prompt, **options): assert result.stdout == (cli_text if passed else '') +@pytest.mark.parametrize('text, passed', [ + ('Template:\n```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```\nDone.', True), + ('Template:\n```\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```', True), + ('Template: {"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}', False), + ('Template:\n```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}\n```', False), + ('Template:\n```json\n{"Resources":{"Subnet":{"Type":"ALIYUN::ECS::VSwitch"}}}\n```', False), + ('Receipt:\n```json\n{"kind":"permission_ack","target":"vpc.json"}\n```', False), + ('Envelope:\n```json\n{"result":{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}}\n```', False), +]) +def test_resolved_permission_accepts_complete_fenced_final_template(monkeypatch, tmp_path, text, passed): + task = _completed_task(text) + task['status']['state'] = 'TASK_STATE_INPUT_REQUIRED' + task['metadata'] = {'iac_code': {'input': None, 'pendingPermissions': []}} + calls, _probes, _clock = _task_flow(monkeypatch, [_permission_task(tmp_path), task]) + monkeypatch.setattr(smoke, '_normal_turn_completed', lambda *args, **kwargs: True) + result = smoke.run_a2a_client_call('JSON only', cwd=str(tmp_path)) + assert (result.returncode == 0) is passed and len(calls) == 2 + assert result.stdout == (text if passed else '') + + +def test_fenced_template_is_not_extracted_while_other_permissions_remain(): + text = 'Template:\n```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```' + task = _completed_task(text) + task['metadata'] = {'iac_code': {'input': None, 'pendingPermissions': [{'toolUseId': 'other-tool'}]}} + assert smoke._normal_turn_output(task, text, permission_submitted=True) == '' + + @pytest.mark.parametrize('updates, completed', [ ({}, True), ({'taskId': 'other-task'}, False), ({'contextId': 'other-context'}, False), ({'phase': 'running'}, False), ({'terminationReason': 'explicit'}, False), ({'releaseReady': False}, False), From 6dbc0b3790ddd667ea8b044620a86a84d57ef3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Fri, 23 Oct 2026 11:22:21 +0800 Subject: [PATCH 12/16] fix(pipeline): preserve consultation answers and coupon uncertainty Keep deployment follow-up questions in planning and distinguish them from standalone console consultations. Preserve unknown amounts and avoid reusing an earlier coupon after a refresh failure. The E2E runner asks once for a missing visible answer only on the same waiting task, within the original timeout, and reports that follow-up count. Existing financial, transport and deployment-boundary acceptance checks remain in force. --- scripts/ci/run_coupon_budget_mock_a2a.py | 44 ++++++++- scripts/ci/run_e2e.py | 1 + .../pipeline/selling/references/coupons.md | 4 +- .../solution_planning_and_selection.md | 4 +- .../ros-agent-awareness.md | 5 +- tests/scripts/test_coupon_budget_mock_a2a.py | 91 +++++++++++++++++++ 6 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 tests/scripts/test_coupon_budget_mock_a2a.py diff --git a/scripts/ci/run_coupon_budget_mock_a2a.py b/scripts/ci/run_coupon_budget_mock_a2a.py index e59ec7132..6c013e05e 100644 --- a/scripts/ci/run_coupon_budget_mock_a2a.py +++ b/scripts/ci/run_coupon_budget_mock_a2a.py @@ -24,6 +24,7 @@ from scripts.a2a.e2e.common import ( # noqa: E402 ManagedServer, + StreamSummary, _free_port, _write_server_config, stream_message, @@ -118,6 +119,39 @@ def recorded_calls(path: Path) -> list[dict[str, Any]]: return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] +def _answer_turn( + *, name: str, timeout: float, **stream_args: Any, +) -> tuple[StreamSummary, Path, str, bool, bool]: + deadline = time.monotonic() + timeout + summary = stream_message(prompt=QUESTION, name=name, timeout=timeout, **stream_args) + path = stream_args["run_dir"] / f"{name}.events.jsonl" + text = visible_pipeline_text(path) + transport_ok = summary.response_content_type == "text/event-stream" + conclusion, steps = pipeline_facts(path) + remaining = deadline - time.monotonic() + followup = ( + not text.strip() and remaining > 0 + and summary.last_status_state == "TASK_STATE_INPUT_REQUIRED" + and not summary.normal_handoff_ready and conclusion.get("status") == "awaiting_selection" + and "materialize_selected_candidate" not in steps + ) + if followup: + # A tool-only completion did not answer the user's question. Ask once + # on the same waiting task; the original financial/flow checks still + # evaluate the actual model reply, never the tool arguments or a fixture. + summary = stream_message( + prompt=(QUESTION + "\n上一条请求还没有收到可见答复。请给出当前这张券的核算表格," + "无法确认的金额标为待确认,不要猜测或复用前一张券。" + "仅补充答复,保持当前部署方案,不选择、不重新规划、不下单或用券。"), + name=f"{name}-answer", timeout=remaining, + **{**stream_args, "context_id": summary.context_id, "task_id": summary.task_id}, + ) + path = stream_args["run_dir"] / f"{name}-answer.events.jsonl" + text = visible_pipeline_text(path) + transport_ok = transport_ok and summary.response_content_type == "text/event-stream" + return summary, path, text, followup, transport_ok + + def run(args: argparse.Namespace) -> dict[str, Any]: config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): @@ -167,21 +201,19 @@ def run(args: argparse.Namespace) -> dict[str, Any]: start = time.monotonic() before = len(recorded_calls(ledger)) prior_tool_ids = {call["id"] for call in tool_inputs(config_dir)} - summary = stream_message( - server_url=server_url, cwd=str(workspace), prompt=QUESTION, + summary, path, text, answer_followup, transport_ok = _answer_turn( + server_url=server_url, cwd=str(workspace), context_id=context_id, task_id=task_id, name=case, run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, ) context_id, task_id = summary.context_id, summary.task_id - path = args.run_dir / f"{case}.events.jsonl" - text = visible_pipeline_text(path) # Fixtures contain no real user data. Keep the model answer for human review. (args.run_dir / f"{case}.answer.txt").write_text(text, encoding="utf-8") conclusion, steps = pipeline_facts(path) calls = recorded_calls(ledger)[before:] cell = cash_cell(text) local = { - "real HTTP/SSE": summary.response_content_type == "text/event-stream", + "real HTTP/SSE": transport_ok, "queried current coupon fixture": any( call.get("case") == case and call.get("action") == "DescribeCoupon" and call.get("mocked") is True for call in calls @@ -214,6 +246,7 @@ def run(args: argparse.Namespace) -> dict[str, Any]: "case": case, "title": CASES[case].title, "expected_cash": CASES[case].cash, "cash_cell": cell, "passed": all(local.values()), "checks": local, "assistant_reference_present": has_assistant_reference(text), + "visible_answer_followup": answer_followup, "seconds": round(time.monotonic() - start, 2), } rows.append(row) @@ -240,6 +273,7 @@ def run(args: argparse.Namespace) -> dict[str, Any]: return { "status": "passed" if all(checks.values()) else "failed", "checks": checks, "cases": rows, "model": args.model, "observed_models": observed, + "diagnostics": {"coupon_visible_answer_followup_count": sum(row["visible_answer_followup"] for row in rows)}, "coupon_api_mocked": True, "llm_mocked": False, "settlement_verified": False, } diff --git a/scripts/ci/run_e2e.py b/scripts/ci/run_e2e.py index 5a13e9125..717104d50 100644 --- a/scripts/ci/run_e2e.py +++ b/scripts/ci/run_e2e.py @@ -766,6 +766,7 @@ def _public_live_summary(summary: dict[str, Any] | None, cleanup_status: str | N "text_exit_code", "text_output_length", "smoke_sync_output_length", "smoke_sync_task_count", "smoke_sync_trailing_agent_message_count", "smoke_sync_permission_response_count", + "coupon_visible_answer_followup_count", "cleanup_turn_event_count", "cleanup_turn_cleanup_event_count", "cleanup_target_count", "cleanup_ledger_pending_count", "cleanup_delete_tool_use_count", "cleanup_get_tool_use_count", "cleanup_failure_event_count", "cleanup_delete_http_status", "cleanup_get_http_status", diff --git a/src/iac_code/pipeline/selling/references/coupons.md b/src/iac_code/pipeline/selling/references/coupons.md index db8223f61..1cc24dac1 100644 --- a/src/iac_code/pipeline/selling/references/coupons.md +++ b/src/iac_code/pipeline/selling/references/coupons.md @@ -50,7 +50,7 @@ aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCouponIte - **明确不可抵扣**:券已过期、用尽或作废,或者当前方案违反已返回的付费方式、商品排除、GPU 限制、金额门槛等规则。该券对本次费用的可抵扣金额为 0,不能再假设“也许仍能抵扣”。`Status` 为 `EXPIRED` 或 `EndTime` 已早于当前时间时,即使 `RemainAmount` 仍有数字,也不是有效余额;若只有这一张券,已知的本次费用需全部另付现金。 - **条件性可抵扣**:券有效且余额、适用商品及所有已知限制均匹配时,按下面的费用范围计算;仍保留实际结算说明。 -- **待确认**:查询失败或缺少判断目标配置所必需的信息。不要把缺失信息当成已满足,也不要把已经明确不满足的条件重新写成待确认。 +- **待确认**:查询失败或缺少判断目标配置所必需的信息。不要把缺失信息当成已满足,也不要把已经明确不满足的条件重新写成待确认。适用性未确认时,可抵扣金额和需补现金也标为待确认,不能填入看似确定的金额后只在备注中说明未知;如展示假设计算,每个相关金额须明确标注“若适用”等前提。 ## 结合方案解释费用 @@ -63,6 +63,8 @@ aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCouponIte 接口成功但列表为空,只表示本次查询未返回券;权限不足、认证失败、分页未完成或服务异常都不能解释为没有券。保留当前部署方案,说明具体未知项,不编造抵扣结果。 +用户明确要求重新查询或换用另一张券,而本轮查询失败时,不得把此前成功查询的券当成本轮依据;当前券余额、可抵扣金额和需补现金均保留待确认,不能沿用上一张券的门槛或排除规则计算。 + 所需只读权限分别为 `bss:DescribeCoupon` 和 `bss:DescribeCouponItemList`(资源 `*`);费用中心或子账号还可能因折扣查询权限受限,按接口实际错误提示处理,不自行改权限。查询 API 不会领取、使用、冻结或验证实际结算抵扣。 ## 官方参考 diff --git a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md index c9d8554f2..cb1401130 100644 --- a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md +++ b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md @@ -25,13 +25,14 @@ ### 选择恢复或回滚重规划 -上方状态为 `awaiting_selection` 时,用户已经看过候选。本轮消息只能按以下三类处理: +上方状态为 `awaiting_selection` 时,用户已经看过候选。本轮消息按以下四类处理: 1. **选择候选**:按候选坐标、唯一名称或自然语言偏好映射到已保存的候选,只提交下面的选择增量;不要重复 candidates、intent、options 或 selected_candidate。 2. **修改当前架构**:结合用户新增要求重新规划,用一次新的 `show_architecture_plan` 提交修改后的完整摘要 批次,再逐个细化;不得提交增量 patch,也不得把架构修改误报为已选择。 3. **替换部署目标**:本轮最新输入成为新的权威需求,丢弃旧 `intent`、候选和产品组合,重新执行技能的 意图、摘要批次和逐候选细化流程。新旧部署需求不得合并。 +4. **咨询当前方案**:配置、费用或用券适用性等追问先按事实可见答复,按需查询,并使用用户要求的表格等格式;不把咨询当成选择或取消部署。没有修改要求时保留候选并提交 `awaiting_selection`,不重新规划或进入实现。 用户只要求“重新选择方案”且没有增加架构要求或替换部署目标时,不要重新生成、展示或重复提交候选;提交 `{"conclusion":{"status":"awaiting_selection"}}`,Python 会从已保存的 `solution_selection` 恢复原候选并重新打开选择界面。 @@ -43,7 +44,6 @@ 本 Step 只选择架构,不接收部署参数。输入中的 `parameter_overrides`、`deployment_parameters` 或 `parameters` 不写入结论,部署参数统一交给下一步处理。 选择分支的 `complete_step` 形状: - `{"conclusion":{"status":"selected","selected_candidate_index":0}}` 首次规划或用户明确修改架构/替换部署目标时须提交包含 `status` 和 `intent` 的 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md index 84eabcf31..24b6fb588 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md @@ -1 +1,4 @@ -ROS console override: Deployment coupon/budget stays here even if no coupon applies. For standalone billing, promos/renewals, prices/availability, traffic offsets or selection promos, first OUTPUT {ai_assistant_destination} as visible text, NOT only tool input; then only `complete_step` with `status: rejected`, full URL in `rejection_reason`; override clarification/reselection. Questions are not selections. Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. +ROS console override: +1) Deployment coupon/budget: read coupons.md, query/answer visibly; keep `awaiting_selection`; NEVER link/reject. +2) ELSE standalone billing, renewals/promos, prices/availability or traffic/selection promos: OUTPUT {ai_assistant_destination} as visible text, NOT only tool input; only `complete_step` with `status: rejected`, full URL in `rejection_reason`; override clarification/reselection. +Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. diff --git a/tests/scripts/test_coupon_budget_mock_a2a.py b/tests/scripts/test_coupon_budget_mock_a2a.py new file mode 100644 index 000000000..6074f50b0 --- /dev/null +++ b/tests/scripts/test_coupon_budget_mock_a2a.py @@ -0,0 +1,91 @@ +from types import SimpleNamespace + +import pytest + +from scripts.ci import run_coupon_budget_mock_a2a as runner + + +def _summary(**updates): + return SimpleNamespace( + **dict(context_id="context-one", task_id="task-one", normal_handoff_ready=False, + last_status_state="TASK_STATE_INPUT_REQUIRED", response_content_type="text/event-stream") | updates, + ) + + +@pytest.mark.parametrize("first_text, updates, status, elapsed, expected", [ + ("", {}, "awaiting_selection", 3, True), + ("", {"normal_handoff_ready": True}, "awaiting_selection", 3, False), + ("", {}, "rejected", 3, False), + ("", {}, "selected", 3, False), + ("", {"last_status_state": "TASK_STATE_FAILED"}, "awaiting_selection", 3, False), + ("", {}, "awaiting_selection", 10, False), + ("Incorrect amount: 0", {}, "awaiting_selection", 3, False), +]) +def test_only_missing_visible_answer_gets_one_same_task_followup( + monkeypatch, tmp_path, first_text, updates, status, elapsed, expected, +): + calls = [] + clock = iter([0, elapsed]) + monkeypatch.setattr(runner.time, "monotonic", lambda: next(clock)) + + def stream(**options): + calls.append(options) + return _summary(**updates) if len(calls) == 1 else _summary() + + monkeypatch.setattr(runner, "stream_message", stream) + monkeypatch.setattr(runner, "pipeline_facts", lambda _: ({"status": status}, [])) + monkeypatch.setattr(runner, "visible_pipeline_text", lambda p: + "Actual model answer" if "-answer." in p.name else first_text) + _final, path, text, followed_up, transport_ok = runner._answer_turn( + name="fixture", timeout=10, run_dir=tmp_path, server_url="http://127.0.0.1:1", + context_id="context-one", task_id="task-one", + ) + assert followed_up is expected and len(calls) == 1 + int(expected) + assert text == ("Actual model answer" if expected else first_text) + assert transport_ok is True + if expected: + assert calls[1]["prompt"].startswith(runner.QUESTION) + assert calls[1]["context_id"] == "context-one" and calls[1]["task_id"] == "task-one" + assert calls[1]["timeout"] == 7 and path.name == "fixture-answer.events.jsonl" + + +def test_followup_cannot_replace_a_missing_answer_or_bad_transport_with_success(monkeypatch, tmp_path): + calls = [] + monkeypatch.setattr(runner.time, "monotonic", lambda: 0) + + def stream(**options): + calls.append(options) + return _summary(response_content_type="application/json" if len(calls) == 1 else "text/event-stream") + + monkeypatch.setattr(runner, "stream_message", stream) + monkeypatch.setattr(runner, "pipeline_facts", lambda _: ({"status": "awaiting_selection"}, [])) + monkeypatch.setattr(runner, "visible_pipeline_text", lambda _: "") + _final, _path, text, followed_up, transport_ok = runner._answer_turn( + name="fixture", timeout=10, run_dir=tmp_path, context_id="context-one", task_id="task-one", + ) + assert followed_up and len(calls) == 2 + assert text == "" and transport_ok is False + assert not any(runner.evaluate_answer("applicable", text).values()) + + +def test_followup_count_survives_safe_report_projection_without_answer_payload(): + from scripts.ci import run_e2e + + diagnostics = {"coupon_visible_answer_followup_count": 1, "answer": "private model response"} + public = run_e2e._public_live_summary({"diagnostics": diagnostics}) + assert public["diagnostics"] == {"coupon_visible_answer_followup_count": 1} + diagnostics["coupon_visible_answer_followup_count"] = "private response" + assert not run_e2e._public_live_summary({"diagnostics": diagnostics}).get("diagnostics") + + +def test_silent_implementation_transition_is_not_recovered_as_a_waiting_answer(monkeypatch, tmp_path): + calls = [] + monkeypatch.setattr(runner.time, "monotonic", lambda: 0) + monkeypatch.setattr(runner, "stream_message", lambda **kwargs: calls.append(kwargs) or _summary()) + monkeypatch.setattr(runner, "pipeline_facts", lambda _: + ({"status": "awaiting_selection"}, ["materialize_selected_candidate"])) + monkeypatch.setattr(runner, "visible_pipeline_text", lambda _: "") + _final, _path, text, followed_up, _transport = runner._answer_turn( + name="fixture", timeout=10, run_dir=tmp_path, context_id="context-one", task_id="task-one", + ) + assert len(calls) == 1 and not followed_up and text == "" From e1797f6cf52c47b8a1ccb6da5bad9e69ff928961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Fri, 23 Oct 2026 14:58:32 +0800 Subject: [PATCH 13/16] test: stabilize Windows pipeline readiness and polling clocks --- tests/a2a/test_transport_dispatcher.py | 27 ++++++++++++++++--- ...st_selling_solution_first_run_scenarios.py | 22 +++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/tests/a2a/test_transport_dispatcher.py b/tests/a2a/test_transport_dispatcher.py index 4bf1f8fff..e2fc676a9 100644 --- a/tests/a2a/test_transport_dispatcher.py +++ b/tests/a2a/test_transport_dispatcher.py @@ -6691,6 +6691,10 @@ async def handle(self, request): class _PublicStoppedPipelineFixture: """Exercise full SDK dispatch and real Runner/Stop/durable successor claim.""" + # Initial dispatch and recovery both construct real runners and persist + # session/backup state. They use the same integration readiness budget, + # separate from the five-second mock-stream delivery assertions. + model_ready_timeout = 20 task_id = "task-public-stop" context_id = "ctx-public-stop" original_text = "Remember planning marker stopped-sdk and network 10.242.0.0/16." @@ -6826,6 +6830,23 @@ def send(self, request, *, close_at_canceled=False): self.sending.append(sending) return sending + async def wait_for_model(self, ready, sending) -> None: + waiter = asyncio.create_task(ready.wait()) + try: + done, _ = await asyncio.wait( + (waiter, sending), timeout=self.model_ready_timeout, return_when=asyncio.FIRST_COMPLETED + ) + if sending in done: + # Preserve an early producer exception rather than reporting a + # misleading model-start timeout after the stream already died. + await sending + raise AssertionError("Pipeline stream ended before its blocked model call was reached") + assert waiter in done, "Pipeline did not reach its blocked model call within the readiness budget" + await waiter + finally: + waiter.cancel() + await asyncio.gather(waiter, return_exceptions=True) + async def prepare_stopped(self) -> None: ctx = await self.store.get_or_create_context( context_id=self.context_id, @@ -6840,7 +6861,7 @@ async def prepare_stopped(self) -> None: ) old_request = self.request(task_id=self.task_id, message_id="public-stop-first", text=self.original_text) first = self.send(old_request, close_at_canceled=True) - await asyncio.wait_for(self.model.first_model.wait(), timeout=_STREAM_TEST_TIMEOUT) + await self.wait_for_model(self.model.first_model, first) self.control = self.service.get_for_context(self.context_id) assert self.control is not None and self.control.task_id == self.task_id ctx = await self.store.get_context_record(self.context_id) @@ -6934,9 +6955,7 @@ async def test_public_sdk_pipeline_stop_reentry_omitted_task_uses_real_stop_proo assert fixture.model.business_calls == 1 else: sending = fixture.send(request) - # This path replays real Pipeline/SDK recovery and durable writes; - # Windows file IO can exceed the mock-stream tests' five seconds. - await asyncio.wait_for(fixture.model.resumed_model.wait(), timeout=20) + await fixture.wait_for_model(fixture.model.resumed_model, sending) successor = request.message.task_id assert successor and successor != fixture.task_id intent = intent_store.load(context_id=fixture.context_id) diff --git a/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py b/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py index 967ccdb3d..6791aedd8 100644 --- a/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py +++ b/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py @@ -55,6 +55,19 @@ def runner() -> ModuleType: return _runner_module() +@pytest.fixture +def repl_poll_clock(runner, monkeypatch): + # These tests check polling decisions, not filesystem speed. Advance time + # only at sleeps so a busy Windows runner still performs the first read. + now = [0.0] + + def sleep(seconds): + now[0] += seconds + + monkeypatch.setattr(runner, 'time', SimpleNamespace(monotonic=lambda: now[0], sleep=sleep)) + return now + + @pytest.mark.parametrize(('depends_on_old', 'expected_count'), [(False, 0), (True, 1)]) def test_cleanup_dependency_probe_uses_accepted_stacks_and_exports_no_identity( runner, tmp_path, monkeypatch, capsys, depends_on_old, expected_count, @@ -211,7 +224,9 @@ def send(self, text, **kwargs): @pytest.mark.parametrize('terminal', [None, 'pipeline_failed', 'pipeline_completed']) -def test_multimodal_wait_without_native_input_fails_without_sending_image(runner, tmp_path, monkeypatch, terminal): +def test_multimodal_wait_without_native_input_fails_without_sending_image( + runner, tmp_path, monkeypatch, terminal, repl_poll_clock, +): pipeline = tmp_path / 'projects/p/s/pipeline' pipeline.mkdir(parents=True) if terminal: @@ -227,6 +242,9 @@ def test_multimodal_wait_without_native_input_fails_without_sending_image(runner runner._repl_wait_multimodal_confirmation(runtime, pty, primary_image_key='ask-first-answer', phase='initial') if not terminal: assert runtime.checks['REPL display user_input_required occurrence 1 observed'] is False + assert repl_poll_clock[0] == 0.1 + else: + assert repl_poll_clock[0] == 0.0 def test_multimodal_confirmation_ignores_confirmation_before_latest_selection(runner, tmp_path, monkeypatch): @@ -254,7 +272,7 @@ def drain(): assert pty.events[-1]['occurrence'] == 2 -def test_image_question_does_not_resend_while_original_question_is_unacknowledged(runner, tmp_path): +def test_image_question_does_not_resend_while_original_question_is_unacknowledged(runner, tmp_path, repl_poll_clock): meta = tmp_path / 'projects/p/s/pipeline/meta.yaml' meta.parent.mkdir(parents=True) meta.write_text(yaml.safe_dump({'current_step': runner.NEW_STEPS[1], 'execution': { From cce699c036a5746fa0b32892438f5852737fea09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Fri, 23 Oct 2026 20:41:23 +0800 Subject: [PATCH 14/16] fix(pipeline): answer coupon consultations directly and preserve billing coverage Require a visible answer to the original coupon question without a runner follow-up. Clarify coupon estimates and explicit read-only queries while preserving the current plan. Keep natural and explicit billing consultation cases in the live suite, with regression coverage and updated runner documentation. --- scripts/ci/README.md | 4 +- scripts/ci/run_coupon_budget_mock_a2a.py | 45 +----- scripts/ci/run_ros_agent_awareness.py | 3 +- .../pipeline/selling/references/coupons.md | 3 + .../solution_planning_and_selection.md | 2 + tests/scripts/test_ci_run_e2e.py | 15 +- tests/scripts/test_coupon_budget_mock_a2a.py | 133 ++++++++---------- 7 files changed, 88 insertions(+), 117 deletions(-) diff --git a/scripts/ci/README.md b/scripts/ci/README.md index a58fa21db..aed31da2a 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -63,7 +63,7 @@ uv run --no-sync python scripts/ci/run_e2e.py \ --case ssf-candidate-batch-cold-recovery --jobs 1 --retries 0 ``` -`ros-awareness-*` 纳入 `live-readonly`,验证生产 Step 1 引擎和工具在真实模型下的分流:优惠、续费、计费、GPU 库存价格、流量抵扣、默认地域,以及开关关闭、部署成本咨询两个对照场景。验收用户可见链接、地域、持久化结束原因及澄清边界;不替换模型输出,不覆盖 HTTP/UI,不调用云 API。只复制 LLM 凭证和 settings,不接收云凭证或调用云凭证 helper。可定向运行: +`ros-awareness-*` 纳入 `live-readonly`,验证生产 Step 1 引擎和工具在真实模型下的分流:优惠、续费、计费、GPU 库存价格、流量抵扣、默认地域,以及开关关闭、部署成本咨询两个对照场景。计费保留自然问法 `ros-awareness-billing` 和明确不部署的问法 `ros-awareness-billing-explicit`,两者均须正确分流。验收用户可见链接、地域、持久化结束原因及澄清边界;不替换模型输出,不覆盖 HTTP/UI,不调用云 API。只复制 LLM 凭证和 settings,不接收云凭证或调用云凭证 helper。可定向运行: ```bash uv run --no-sync python scripts/ci/run_e2e.py \ @@ -149,7 +149,7 @@ REPL 旧场景的 PTY 等待每 10 秒检查一次,每 60 秒在单用例的 s 先按 `report.md` 找到失败用例与首次失败检查,再看对应的 `ci-result.json`、场景 `summary.json`、日志和源码。Agent 应做有界复现,明确归类为产品缺陷、用例/断言缺陷、环境/凭证故障、云资源清理故障或超时,并写出证据、受影响场景和建议修复。配置的一次自动重跑之外,真实云用例再次复现前先核对残留资源与清理结果。报告里的“初步线索”只是索引,不能替代复盘结论。 -总入口目前登记 198 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 +总入口目前登记 199 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 ### 验收边界 diff --git a/scripts/ci/run_coupon_budget_mock_a2a.py b/scripts/ci/run_coupon_budget_mock_a2a.py index 6c013e05e..c5946d203 100644 --- a/scripts/ci/run_coupon_budget_mock_a2a.py +++ b/scripts/ci/run_coupon_budget_mock_a2a.py @@ -24,7 +24,6 @@ from scripts.a2a.e2e.common import ( # noqa: E402 ManagedServer, - StreamSummary, _free_port, _write_server_config, stream_message, @@ -119,39 +118,6 @@ def recorded_calls(path: Path) -> list[dict[str, Any]]: return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] -def _answer_turn( - *, name: str, timeout: float, **stream_args: Any, -) -> tuple[StreamSummary, Path, str, bool, bool]: - deadline = time.monotonic() + timeout - summary = stream_message(prompt=QUESTION, name=name, timeout=timeout, **stream_args) - path = stream_args["run_dir"] / f"{name}.events.jsonl" - text = visible_pipeline_text(path) - transport_ok = summary.response_content_type == "text/event-stream" - conclusion, steps = pipeline_facts(path) - remaining = deadline - time.monotonic() - followup = ( - not text.strip() and remaining > 0 - and summary.last_status_state == "TASK_STATE_INPUT_REQUIRED" - and not summary.normal_handoff_ready and conclusion.get("status") == "awaiting_selection" - and "materialize_selected_candidate" not in steps - ) - if followup: - # A tool-only completion did not answer the user's question. Ask once - # on the same waiting task; the original financial/flow checks still - # evaluate the actual model reply, never the tool arguments or a fixture. - summary = stream_message( - prompt=(QUESTION + "\n上一条请求还没有收到可见答复。请给出当前这张券的核算表格," - "无法确认的金额标为待确认,不要猜测或复用前一张券。" - "仅补充答复,保持当前部署方案,不选择、不重新规划、不下单或用券。"), - name=f"{name}-answer", timeout=remaining, - **{**stream_args, "context_id": summary.context_id, "task_id": summary.task_id}, - ) - path = stream_args["run_dir"] / f"{name}-answer.events.jsonl" - text = visible_pipeline_text(path) - transport_ok = transport_ok and summary.response_content_type == "text/event-stream" - return summary, path, text, followup, transport_ok - - def run(args: argparse.Namespace) -> dict[str, Any]: config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): @@ -201,19 +167,22 @@ def run(args: argparse.Namespace) -> dict[str, Any]: start = time.monotonic() before = len(recorded_calls(ledger)) prior_tool_ids = {call["id"] for call in tool_inputs(config_dir)} - summary, path, text, answer_followup, transport_ok = _answer_turn( - server_url=server_url, cwd=str(workspace), + summary = stream_message( + server_url=server_url, cwd=str(workspace), prompt=QUESTION, context_id=context_id, task_id=task_id, name=case, run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, ) context_id, task_id = summary.context_id, summary.task_id + path = args.run_dir / f"{case}.events.jsonl" + text = visible_pipeline_text(path) # Fixtures contain no real user data. Keep the model answer for human review. (args.run_dir / f"{case}.answer.txt").write_text(text, encoding="utf-8") conclusion, steps = pipeline_facts(path) calls = recorded_calls(ledger)[before:] cell = cash_cell(text) local = { - "real HTTP/SSE": transport_ok, + "real HTTP/SSE": summary.response_content_type == "text/event-stream", + "visible answer returned for the original question": bool(text.strip()), "queried current coupon fixture": any( call.get("case") == case and call.get("action") == "DescribeCoupon" and call.get("mocked") is True for call in calls @@ -246,7 +215,6 @@ def run(args: argparse.Namespace) -> dict[str, Any]: "case": case, "title": CASES[case].title, "expected_cash": CASES[case].cash, "cash_cell": cell, "passed": all(local.values()), "checks": local, "assistant_reference_present": has_assistant_reference(text), - "visible_answer_followup": answer_followup, "seconds": round(time.monotonic() - start, 2), } rows.append(row) @@ -273,7 +241,6 @@ def run(args: argparse.Namespace) -> dict[str, Any]: return { "status": "passed" if all(checks.values()) else "failed", "checks": checks, "cases": rows, "model": args.model, "observed_models": observed, - "diagnostics": {"coupon_visible_answer_followup_count": sum(row["visible_answer_followup"] for row in rows)}, "coupon_api_mocked": True, "llm_mocked": False, "settlement_verified": False, } diff --git a/scripts/ci/run_ros_agent_awareness.py b/scripts/ci/run_ros_agent_awareness.py index 1ce01721b..7afe1b9ed 100644 --- a/scripts/ci/run_ros_agent_awareness.py +++ b/scripts/ci/run_ros_agent_awareness.py @@ -31,7 +31,8 @@ class Scenario: SCENARIOS = { "promotions": Scenario("阿里云现在有什么优惠活动和优惠券?我只想咨询,暂时不部署资源。"), "renewal": Scenario("已有的 ECS 快到期了,续费有折扣吗?"), - "billing": Scenario("我只咨询已有 ECS 的按量账单,不打算部署或创建资源。按量付费如何计费,欠费后怎么办?"), + "billing": Scenario("按量付费的 ECS 是如何计费的?欠费后怎么办?"), + "billing-explicit": Scenario("我只咨询已有 ECS 的按量账单,不打算部署或创建资源。按量付费如何计费,欠费后怎么办?"), "gpu-availability": Scenario("北京地域的 GPU 实例现在有哪些库存和单价?暂时不部署。", region="cn-shanghai"), "traffic-offset": Scenario("有没有能抵扣已有 ECS 公网流量费的流量包?"), "default-region": Scenario("我只想咨询阿里云的续费优惠,暂时不创建资源。", region=None), diff --git a/src/iac_code/pipeline/selling/references/coupons.md b/src/iac_code/pipeline/selling/references/coupons.md index 1cc24dac1..35d1b2b64 100644 --- a/src/iac_code/pipeline/selling/references/coupons.md +++ b/src/iac_code/pipeline/selling/references/coupons.md @@ -57,6 +57,7 @@ aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCouponIte - 保持配置、地域、数量、计费方式和使用时长一致,再比较费用与可抵扣额度。按小时租用时使用有依据的按量单价,不用包月价除以固定小时数替代。 - 分别说明资源粗估、已确认的券余额与适用条件,以及未确认的抵扣范围。优惠券余额不是折后资源单价,不直接从月费中减掉券面值来生成“每月优惠价”。 - 只有代金券的适用范围及其限制已确认、且预计费用全部在可抵扣范围内时,才可给出条件性估算:预计需补现金为 `max(预计费用 - 可用余额, 0)`;仍注明实际抵扣以订单或账单为准。满减、折扣、门槛、部分费用不适用或多券叠加均不得套用该简式。 +- 条件已确认且可以计算时,现金栏填写带“预计”等限定的金额,实际结算说明单独列出。仅仅因为最终以账单结算为准,不把本来可以计算的预计金额改成“待确认”;缺少适用条件证据时才保留未知金额。 - 仅明确按小时付费、未给出使用时长时,先展示小时粗估。只有抵扣条件足够明确时,才估算余额可覆盖的大致时长,并列出未覆盖的云盘、网络等费用;不能承诺券用完就自动停机或不再扣费。 ## 查询失败与能力边界 @@ -65,6 +66,8 @@ aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCouponIte 用户明确要求重新查询或换用另一张券,而本轮查询失败时,不得把此前成功查询的券当成本轮依据;当前券余额、可抵扣金额和需补现金均保留待确认,不能沿用上一张券的门槛或排除规则计算。 +用户已明确要求查询或重新查询时,按现有工具权限直接发起只读查询,不再用 `ask_user_question` 询问是否查询或确认查询方式。若查询后仍有多张券且无法确定目标券,再请用户选择;工具本身要求的权限确认仍走原权限机制。 + 所需只读权限分别为 `bss:DescribeCoupon` 和 `bss:DescribeCouponItemList`(资源 `*`);费用中心或子账号还可能因折扣查询权限受限,按接口实际错误提示处理,不自行改权限。查询 API 不会领取、使用、冻结或验证实际结算抵扣。 ## 官方参考 diff --git a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md index cb1401130..e6fc38454 100644 --- a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md +++ b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md @@ -34,6 +34,8 @@ 意图、摘要批次和逐候选细化流程。新旧部署需求不得合并。 4. **咨询当前方案**:配置、费用或用券适用性等追问先按事实可见答复,按需查询,并使用用户要求的表格等格式;不把咨询当成选择或取消部署。没有修改要求时保留候选并提交 `awaiting_selection`,不重新规划或进入实现。 +每次方案咨询都先输出本轮可见答复,再调用 `complete_step`;只提交等待选择结论不能代替回答。用户重复提问或明确要求重新查询时仍需处理本轮请求,不能因已有候选或之前答过相同文字就省略答复。 + 用户只要求“重新选择方案”且没有增加架构要求或替换部署目标时,不要重新生成、展示或重复提交候选;提交 `{"conclusion":{"status":"awaiting_selection"}}`,Python 会从已保存的 `solution_selection` 恢复原候选并重新打开选择界面。 这个增量只允许用于已有候选的恢复;首次规划或重新规划仍必须提交完整 `intent`,但不得在 diff --git a/tests/scripts/test_ci_run_e2e.py b/tests/scripts/test_ci_run_e2e.py index 7cb5960d2..2d5daaa2c 100644 --- a/tests/scripts/test_ci_run_e2e.py +++ b/tests/scripts/test_ci_run_e2e.py @@ -167,8 +167,8 @@ def test_catalog_includes_headless_surfaces_and_excludes_browser_desktop() -> No full = run_e2e.select_cases(run_e2e.parse_args(["--suite", "full", "--list"])) live = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live", "--list"])) assert len(full) == 66 - assert len(live) == 132 - assert len(run_e2e.CASES) == 198 + assert len(live) == 133 + assert len(run_e2e.CASES) == 199 agui = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live-agui", "--list"])) assert {case.name for case in agui} == {"agui-selector-" + name for name in run_e2e.AGUI_SCENARIOS} assert len(agui) == 6 @@ -243,6 +243,17 @@ def test_ros_awareness_cases_are_live_llm_only_and_keep_all_scenarios(tmp_path: assert run_e2e._live_cleanup_status(case, None) == "not-needed" +def test_ros_awareness_preserves_natural_and_explicit_billing_questions() -> None: + scenarios = run_e2e.ROS_AWARENESS_SCENARIOS + assert scenarios["billing"].prompt == "按量付费的 ECS 是如何计费的?欠费后怎么办?" + assert scenarios["billing-explicit"].prompt == ( + "我只咨询已有 ECS 的按量账单,不打算部署或创建资源。按量付费如何计费,欠费后怎么办?" + ) + assert scenarios["billing"].handoff and scenarios["billing-explicit"].handoff + case_names = {case.name for case in run_e2e.LIVE_CASES} + assert {"ros-awareness-billing", "ros-awareness-billing-explicit"} <= case_names + + def test_live_accepts_llm_only_source_with_cloud_helper(tmp_path: Path) -> None: for name in (".credentials.yml", "settings.yml"): (tmp_path / name).write_text("fixture", encoding="utf-8") diff --git a/tests/scripts/test_coupon_budget_mock_a2a.py b/tests/scripts/test_coupon_budget_mock_a2a.py index 6074f50b0..6e2b1d719 100644 --- a/tests/scripts/test_coupon_budget_mock_a2a.py +++ b/tests/scripts/test_coupon_budget_mock_a2a.py @@ -5,87 +5,74 @@ from scripts.ci import run_coupon_budget_mock_a2a as runner -def _summary(**updates): - return SimpleNamespace( - **dict(context_id="context-one", task_id="task-one", normal_handoff_ready=False, - last_status_state="TASK_STATE_INPUT_REQUIRED", response_content_type="text/event-stream") | updates, - ) - - -@pytest.mark.parametrize("first_text, updates, status, elapsed, expected", [ - ("", {}, "awaiting_selection", 3, True), - ("", {"normal_handoff_ready": True}, "awaiting_selection", 3, False), - ("", {}, "rejected", 3, False), - ("", {}, "selected", 3, False), - ("", {"last_status_state": "TASK_STATE_FAILED"}, "awaiting_selection", 3, False), - ("", {}, "awaiting_selection", 10, False), - ("Incorrect amount: 0", {}, "awaiting_selection", 3, False), +@pytest.mark.parametrize("text, content_type, passed", [ + ("", "text/event-stream", False), + (" ", "text/event-stream", False), + ("|本次计算费可抵扣金额|0元|\n|计算部分需补现金|0元|", "text/event-stream", False), + ("|本次计算费可抵扣金额|待确认|\n|计算部分需补现金|待确认|", "application/json", False), + ("|本次计算费可抵扣金额|待确认|\n|计算部分需补现金|待确认|", "text/event-stream", True), ]) -def test_only_missing_visible_answer_gets_one_same_task_followup( - monkeypatch, tmp_path, first_text, updates, status, elapsed, expected, +def test_coupon_run_requires_the_original_answer_without_a_followup( + monkeypatch, tmp_path, text, content_type, passed, ): + config = tmp_path / "config" + config.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config)) calls = [] - clock = iter([0, elapsed]) - monkeypatch.setattr(runner.time, "monotonic", lambda: next(clock)) + ledger = [] + lifecycle = [] - def stream(**options): - calls.append(options) - return _summary(**updates) if len(calls) == 1 else _summary() + class Server: + def __init__(self, **_kwargs): + pass - monkeypatch.setattr(runner, "stream_message", stream) - monkeypatch.setattr(runner, "pipeline_facts", lambda _: ({"status": status}, [])) - monkeypatch.setattr(runner, "visible_pipeline_text", lambda p: - "Actual model answer" if "-answer." in p.name else first_text) - _final, path, text, followed_up, transport_ok = runner._answer_turn( - name="fixture", timeout=10, run_dir=tmp_path, server_url="http://127.0.0.1:1", - context_id="context-one", task_id="task-one", - ) - assert followed_up is expected and len(calls) == 1 + int(expected) - assert text == ("Actual model answer" if expected else first_text) - assert transport_ok is True - if expected: - assert calls[1]["prompt"].startswith(runner.QUESTION) - assert calls[1]["context_id"] == "context-one" and calls[1]["task_id"] == "task-one" - assert calls[1]["timeout"] == 7 and path.name == "fixture-answer.events.jsonl" + def start(self): + lifecycle.append("started") + def terminate(self): + lifecycle.append("terminated") -def test_followup_cannot_replace_a_missing_answer_or_bad_transport_with_success(monkeypatch, tmp_path): - calls = [] - monkeypatch.setattr(runner.time, "monotonic", lambda: 0) + class Capture: + def __init__(self, _path): + self.env = {} + + def start(self): + return self + + def stop(self): + return [{"attributes": {"gen_ai.request.model": "offline-test-model"}}] def stream(**options): calls.append(options) - return _summary(response_content_type="application/json" if len(calls) == 1 else "text/event-stream") - + if options["prompt"] == runner.QUESTION: + ledger.append({"case": "query_denied", "action": "DescribeCoupon", "mocked": True}) + return SimpleNamespace( + context_id="context-one", task_id="task-one", normal_handoff_ready=False, + last_status_state="TASK_STATE_INPUT_REQUIRED", response_content_type=content_type, + ) + + monkeypatch.setattr(runner, "ManagedServer", Server) + monkeypatch.setattr(runner, "ObserveCapture", Capture) + monkeypatch.setattr(runner, "_free_port", lambda _host: 12345) + monkeypatch.setattr(runner, "_write_server_config", lambda *args, **kwargs: tmp_path / "server.json") + monkeypatch.setattr(runner, "wait_for_server", lambda *args, **kwargs: None) monkeypatch.setattr(runner, "stream_message", stream) - monkeypatch.setattr(runner, "pipeline_facts", lambda _: ({"status": "awaiting_selection"}, [])) - monkeypatch.setattr(runner, "visible_pipeline_text", lambda _: "") - _final, _path, text, followed_up, transport_ok = runner._answer_turn( - name="fixture", timeout=10, run_dir=tmp_path, context_id="context-one", task_id="task-one", - ) - assert followed_up and len(calls) == 2 - assert text == "" and transport_ok is False - assert not any(runner.evaluate_answer("applicable", text).values()) - - -def test_followup_count_survives_safe_report_projection_without_answer_payload(): - from scripts.ci import run_e2e - - diagnostics = {"coupon_visible_answer_followup_count": 1, "answer": "private model response"} - public = run_e2e._public_live_summary({"diagnostics": diagnostics}) - assert public["diagnostics"] == {"coupon_visible_answer_followup_count": 1} - diagnostics["coupon_visible_answer_followup_count"] = "private response" - assert not run_e2e._public_live_summary({"diagnostics": diagnostics}).get("diagnostics") - - -def test_silent_implementation_transition_is_not_recovered_as_a_waiting_answer(monkeypatch, tmp_path): - calls = [] - monkeypatch.setattr(runner.time, "monotonic", lambda: 0) - monkeypatch.setattr(runner, "stream_message", lambda **kwargs: calls.append(kwargs) or _summary()) - monkeypatch.setattr(runner, "pipeline_facts", lambda _: - ({"status": "awaiting_selection"}, ["materialize_selected_candidate"])) - monkeypatch.setattr(runner, "visible_pipeline_text", lambda _: "") - _final, _path, text, followed_up, _transport = runner._answer_turn( - name="fixture", timeout=10, run_dir=tmp_path, context_id="context-one", task_id="task-one", - ) - assert len(calls) == 1 and not followed_up and text == "" + monkeypatch.setattr(runner, "recorded_calls", lambda _path: ledger.copy()) + monkeypatch.setattr(runner, "tool_inputs", lambda _path: [ + {"id": "guide-read", "name": "read_file", "input": {"path": "references/coupons.md"}}, + ]) + monkeypatch.setattr(runner, "visible_pipeline_text", lambda _path: text) + monkeypatch.setattr(runner, "pipeline_facts", lambda _path: ({"status": "awaiting_selection"}, [])) + + result = runner.run(SimpleNamespace( + run_dir=tmp_path, provider="offline-test-provider", model="offline-test-model", + scenario=["query_denied"], timeout=10, + )) + + assert [call["prompt"] for call in calls] == [runner.SETUP, runner.QUESTION] + assert calls[1]["context_id"] == "context-one" and calls[1]["task_id"] == "task-one" + assert calls[1]["timeout"] == 10 + assert result["status"] == ("passed" if passed else "failed") + assert result["cases"][0]["passed"] is passed + assert result["cases"][0]["checks"]["visible answer returned for the original question"] is bool(text.strip()) + assert lifecycle == ["started", "terminated"] From 0f1c07ed67a84487d9c48dcae257ec5aceb26c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Fri, 23 Oct 2026 21:22:01 +0800 Subject: [PATCH 15/16] fix(ci): integrate consultation guidance within the Step 1 prompt contract --- .../prompts/solution_planning_and_selection.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md index e6fc38454..2d5232184 100644 --- a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md +++ b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md @@ -32,9 +32,7 @@ 批次,再逐个细化;不得提交增量 patch,也不得把架构修改误报为已选择。 3. **替换部署目标**:本轮最新输入成为新的权威需求,丢弃旧 `intent`、候选和产品组合,重新执行技能的 意图、摘要批次和逐候选细化流程。新旧部署需求不得合并。 -4. **咨询当前方案**:配置、费用或用券适用性等追问先按事实可见答复,按需查询,并使用用户要求的表格等格式;不把咨询当成选择或取消部署。没有修改要求时保留候选并提交 `awaiting_selection`,不重新规划或进入实现。 - -每次方案咨询都先输出本轮可见答复,再调用 `complete_step`;只提交等待选择结论不能代替回答。用户重复提问或明确要求重新查询时仍需处理本轮请求,不能因已有候选或之前答过相同文字就省略答复。 +4. **咨询当前方案**:配置、费用或用券适用性等追问先按事实可见答复,按需查询,并使用用户要求的表格等格式;不把咨询当成选择或取消部署。每次都先输出本轮可见答复,再调用 `complete_step`;只提交等待选择结论不能代替回答,重复提问或重新查询也不能省略答复。没有修改要求时保留候选并提交 `awaiting_selection`,不重新规划或进入实现。 用户只要求“重新选择方案”且没有增加架构要求或替换部署目标时,不要重新生成、展示或重复提交候选;提交 `{"conclusion":{"status":"awaiting_selection"}}`,Python 会从已保存的 `solution_selection` 恢复原候选并重新打开选择界面。 From f4ffbbe5a0a5bafdc9c79626c2f886c056af0406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Fri, 23 Oct 2026 21:45:27 +0800 Subject: [PATCH 16/16] test(ci): isolate REPL checkpoint ordering from filesystem latency --- .../pipeline_e2e/test_selling_solution_first_run_scenarios.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py b/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py index 6791aedd8..dcffebec8 100644 --- a/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py +++ b/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py @@ -2831,7 +2831,10 @@ def drain_output(self) -> None: @pytest.mark.parametrize("allow_free_text", [True, False]) def test_repl_running_step2_answers_native_question_before_template_fault( runner: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, allow_free_text: bool, + repl_poll_clock, ) -> None: + # Verify question/tool ordering independently of disk latency on loaded CI workers. + # The separate deadline test below explicitly advances the polling clock. directory = tmp_path / "projects/project/session/pipeline" transcript = directory / "transcripts/step2/session.jsonl" transcript.parent.mkdir(parents=True)