diff --git a/pyproject.toml b/pyproject.toml index 76bf47d1..33ccbb57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ dev = [ "setuptools>=68.0", "wheel", "tomli>=2.0; python_version<\"3.11\"", + "pexpect>=4.9.0", ] [build-system] diff --git a/scripts/README.md b/scripts/README.md index e793b335..2217ca32 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,6 +9,8 @@ repository root with `uv run python ...` unless a script-specific README says ot | --- | --- | | `a2a/debugger.py` | Web debugger/client for A2A pipeline streams. | | `a2a/debugger.md` | Manual usage notes for the A2A debugger. | +| `a2a/selling_console.py` | Selling pipeline console local HTTP server; proxies text-only UI requests to an A2A server. | +| `a2a/selling_console_web/` | Static Selling Console frontend. It renders pipeline progress, candidate cards, chat, and debug panels; image input coverage belongs to `a2a/debugger.py`. | | `a2a/e2e/` | A2A session recovery end-to-end scenario runner, shared helpers, and result notes. | | `a2a/smoke/test_a2a_vpc.py` | Small manual smoke script for A2A VPC/pipeline behavior. | | `acp/smoke/test_acp_vpc.py` | Small manual smoke script for ACP VPC behavior. | @@ -17,14 +19,26 @@ repository root with `uv run python ...` unless a script-specific README says ot | `observability/local_observe/` | Local observe server implementation and static web UI. | | `observability/local_observe.md` | Manual usage notes for the local observe tool. | | `rendering/test_diagram_render.py` | Manual diagram rendering check. | +| `repl/e2e/` | Real PTY-driven REPL pipeline end-to-end scenario runner. POSIX-only because it uses `pexpect`. | ## Common Commands ```bash uv run python scripts/a2a/debugger.py --help +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/a2a/selling_console.py --port 41980 \ + --default-server-url http://127.0.0.1:41299 \ + --default-cwd "$PWD" uv run python scripts/a2a/e2e/run_recovery_scenarios.py --help uv run python scripts/observability/local_observe.py --help +uv run python scripts/repl/e2e/run_pipeline_scenarios.py --help ``` +`scripts/repl/e2e/run_pipeline_scenarios.py` writes artifacts under the system temporary directory by default and is intended for manual or smoke validation. It is not part of `make test`; the unit tests only cover helper behavior. The real PTY runner depends on the POSIX-only `pexpect` development dependency. + +The root `conftest.py` includes a tiktoken isolation fixture so tests do not read or write the developer's real encoding cache. Keep new tests on that fixture path rather than using the user cache directly. + +Cleanup ledger temporary files use a leading dot in their generated names only as a cosmetic convention. Correctness relies on atomic replace, retries, and ledger validation, not on Unix hidden-file behavior. + Pytest tests for these helpers live under `tests/`; the executable scripts here are kept for local debugging, manual validation, and real end-to-end runs. diff --git a/scripts/a2a/debugger.md b/scripts/a2a/debugger.md index c03b96df..621f6ddf 100644 --- a/scripts/a2a/debugger.md +++ b/scripts/a2a/debugger.md @@ -30,6 +30,33 @@ uv run python scripts/a2a/debugger.py --port 41880 \ --default-cwd "$PWD" ``` +`--default-cwd` is sent to the A2A server as `metadata.iac_code.cwd` on each +message. It is the server-side workspace for the task, not merely the debugger's +own working directory. + +The A2A server validates this path before running the agent. By default, the +server accepts its own startup directory and Python's temp directory. If +`--default-cwd` points inside an allowed root and the directory does not exist +yet, the server may create it. The request is rejected with +`Invalid A2A workspace metadata.` when the resolved path escapes the allowed +root, cannot be created, or cannot be used as a directory. + +Use one of these patterns: + +```bash +# Start the debugger with a cwd accepted by the already-running server. +uv run python scripts/a2a/debugger.py --port 41880 \ + --default-server-url http://127.0.0.1:41299 \ + --default-cwd "/path/to/server/workspace" +``` + +```bash +# Or explicitly allow the debugger/client workspace when starting the server. +IACCODE_A2A_ALLOWED_CWDS="/path/to/server/workspace:/path/to/client/workspace" \ +IAC_CODE_MODE=pipeline \ +uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 +``` + Open: ```text @@ -67,3 +94,7 @@ uv run python scripts/a2a/debugger.py --port 41880 \ - The debugger is a local development tool and does not provide authentication. - `contextId` identifies the conversation; `taskId` identifies one A2A task. - After `pipeline_handoff_ready`, follow-up messages normally start a new normal-chat task in the same context. +- Image input accepts supported image MIME types only: `image/png`, `image/jpeg`, `image/webp`, and `image/gif`. +- A2A part parser limits: text inline/raw and text `file://` parts are limited to 1 MiB; binary inline/raw/data parts are limited to 5 MiB; binary `file://` parts are limited to 25 MiB. Debugger uploads are limited to 5 MiB per image. +- `file://` image inputs must resolve to an existing local file that is both under the request cwd and under a configured A2A allowed cwd root. Local URLs outside either boundary are rejected. +- The A2A debugger sends image parts. The Selling Console web UI currently sends text input only. diff --git a/scripts/a2a/debugger.py b/scripts/a2a/debugger.py index 8eb94386..2bfbfc79 100644 --- a/scripts/a2a/debugger.py +++ b/scripts/a2a/debugger.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import base64 import errno import html import json @@ -20,6 +21,8 @@ A2A_VERSION_HEADERS = {"A2A-Version": "1.0"} DEBUG_LOG_ROOT_NAME = "iac-code-a2a-debugger-runs" _DEBUG_LOG_LOCK = threading.Lock() +DEBUGGER_SUPPORTED_IMAGE_MEDIA_TYPES = frozenset(("image/png", "image/jpeg", "image/webp", "image/gif")) +DEBUGGER_MAX_IMAGE_BYTES = 5 * 1024 * 1024 @dataclass(frozen=True) @@ -130,6 +133,41 @@ def fetch_json( return ProxyResult(status_code=0, data=None, text="", headers={}, error=str(exc)) +def _normalize_image_part(raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError("images entries must be objects") + media_type = str( + raw.get("mediaType") or raw.get("media_type") or raw.get("mimeType") or raw.get("type") or "", + ).lower() + if media_type not in DEBUGGER_SUPPORTED_IMAGE_MEDIA_TYPES: + supported = ", ".join(sorted(DEBUGGER_SUPPORTED_IMAGE_MEDIA_TYPES)) + raise ValueError(f"images must use one of these mediaType values: {supported}") + + encoded = raw.get("bytes") or raw.get("base64") + if not isinstance(encoded, str) or not encoded: + raise ValueError("images entries must include base64 bytes") + try: + decoded = base64.b64decode(encoded.encode("ascii"), validate=True) + except (ValueError, UnicodeEncodeError) as exc: + raise ValueError("images entries must include valid base64 bytes") from exc + if len(decoded) > DEBUGGER_MAX_IMAGE_BYTES: + raise ValueError("images entries must be 5 MiB or smaller") + + filename = os.path.basename(str(raw.get("filename") or raw.get("name") or "image")) + return { + "data": {"filename": filename or "image", "bytes": encoded}, + "mediaType": media_type, + } + + +def _normalize_image_parts(images: Any) -> list[dict[str, Any]]: + if images in (None, ""): + return [] + if not isinstance(images, list): + raise ValueError("images must be a list") + return [_normalize_image_part(image) for image in images] + + def build_message_stream_payload( *, cwd: str, @@ -138,11 +176,16 @@ def build_message_stream_payload( task_id: str, request_id: str, message_id: str, + images: Any = None, ) -> dict[str, Any]: + parts = [] + if prompt: + parts.append({"text": prompt}) + parts.extend(_normalize_image_parts(images)) message: dict[str, Any] = { "messageId": message_id, "role": "ROLE_USER", - "parts": [{"text": prompt}], + "parts": parts, "metadata": {"iac_code": {"cwd": cwd}}, } if context_id: @@ -206,6 +249,9 @@ def _extract_pipeline_envelope(payload: Any) -> dict[str, Any] | None: return envelope return None + if isinstance(payload.get("eventType") or payload.get("event_type"), str): + return payload + for key in ("pipeline", "pipelineEvent", "pipelineSnapshot"): if isinstance(payload.get(key), dict): return payload[key] @@ -322,11 +368,27 @@ def load_debug_log_export(log_dir: str | Path) -> dict[str, Any]: snapshots = _load_debug_log_raw_values(path / "snapshots.jsonl") requests = _load_debug_log_raw_values(path / "requests.jsonl") latest_snapshot = snapshots[-1] if snapshots else None + snapshot_events = [ + event + for snapshot in snapshots + if isinstance(snapshot, dict) and isinstance(snapshot.get("events"), list) + for event in snapshot["events"] + ] + replay_events = [*sse_events, *snapshot_events] latest_pipeline = None active_task_id = "" task_history: dict[str, dict[str, str]] = {} last_sequence = 0 + def sequence_value(value: Any) -> int | None: + if isinstance(value, bool) or not isinstance(value, int | float): + return None + return int(value) + + def terminal_pipeline_status(value: Any) -> bool: + state = str(value or "").lower().replace("task_state_", "").replace("-", "_") + return state in {"canceled", "cancelled", "failed", "denied", "completed"} + def remember_task(*, task_id: Any, context_id: Any = "", state: Any = "", role: str = "active") -> None: normalized_task_id = str(task_id or "") if not normalized_task_id: @@ -339,7 +401,7 @@ def remember_task(*, task_id: Any, context_id: Any = "", state: Any = "", role: "role": role or existing.get("role") or "active", } - for item in sse_events: + for item in replay_events: identity = _a2a_task_identity(item) if identity is not None: active_task_id = str(identity.get("taskId") or active_task_id) @@ -359,9 +421,13 @@ def remember_task(*, task_id: Any, context_id: Any = "", state: Any = "", role: state=envelope.get("state") or envelope.get("status"), role="pipeline", ) - sequence = envelope.get("sequence") - if isinstance(sequence, int) and not isinstance(sequence, bool): + sequence = sequence_value(envelope.get("sequence")) + if sequence is not None: last_sequence = max(last_sequence, sequence) + if terminal_pipeline_status(envelope.get("state") or envelope.get("status")) and active_task_id == str( + envelope.get("taskId") or "" + ): + active_task_id = "" return { "schemaVersion": "iac-code-a2a-debugger-export-v1", "exportedAt": _utc_now(), @@ -378,7 +444,7 @@ def remember_task(*, task_id: Any, context_id: Any = "", state: Any = "", role: "waitingInput": "", "latestPermission": None, "snapshot": latest_snapshot, - "sseEvents": sse_events, + "sseEvents": replay_events, "requests": requests, "executionTree": {"rootIds": [], "nodes": {}}, "uiState": {}, @@ -504,6 +570,10 @@ def render_index_html(config: DebuggerConfig) -> str: outline: none; } + input[type="file"] { + padding: 7px 10px; + } + input:focus, textarea:focus { border-color: var(--accent); @@ -568,6 +638,18 @@ def render_index_html(config: DebuggerConfig) -> str: gap: 12px; } + .prompt-stack { + display: grid; + gap: 10px; + } + + .image-summary { + min-height: 18px; + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; + } + .debug-log-line { display: flex; align-items: center; @@ -890,6 +972,11 @@ def render_index_html(config: DebuggerConfig) -> str: background: #fef2f2; } + .timeline-canceled { + border-color: #fecaca; + background: #fff1f2; + } + .pill { display: inline-flex; align-items: center; @@ -1315,9 +1402,15 @@ def render_index_html(config: DebuggerConfig) -> str:
- +
+ + +
No images selected.
+
@@ -1400,6 +1493,8 @@ def render_index_html(config: DebuggerConfig) -> str: }; const byId = (id) => document.getElementById(id); + const supportedImageMediaTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); + const maxImageBytes = 5 * 1024 * 1024; function createExecutionTree() { return { @@ -1495,6 +1590,78 @@ def render_index_html(config: DebuggerConfig) -> str: }; } + function selectedImageFiles() { + const input = byId("image-input"); + if (!input || !input.files) { + return []; + } + return Array.from(input.files); + } + + function formatBytes(value) { + const bytes = Number(value) || 0; + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KiB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; + } + + function updateImageSummary() { + const summary = byId("image-summary"); + if (!summary) { + return; + } + const files = selectedImageFiles(); + if (!files.length) { + summary.textContent = "No images selected."; + return; + } + summary.textContent = files + .map((file) => `${file.name || "image"} (${formatBytes(file.size)})`) + .join(", "); + } + + function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => resolve(String(reader.result || ""))); + reader.addEventListener("error", () => reject(reader.error || new Error("Failed to read image file."))); + reader.readAsDataURL(file); + }); + } + + function imageBase64FromDataUrl(dataUrl) { + const commaIndex = dataUrl.indexOf(","); + if (commaIndex < 0) { + throw new Error("Image file did not produce a valid data URL."); + } + return dataUrl.slice(commaIndex + 1); + } + + async function readSelectedImages() { + const files = selectedImageFiles(); + const images = []; + for (const file of files) { + const mediaType = String(file.type || "").toLowerCase(); + if (!supportedImageMediaTypes.has(mediaType)) { + throw new Error(`${file.name || "Selected image"} uses unsupported image type ${mediaType || "unknown"}.`); + } + if (file.size > maxImageBytes) { + throw new Error(`${file.name || "Selected image"} is larger than 5 MiB.`); + } + const dataUrl = await readFileAsDataUrl(file); + images.push({ + filename: file.name || "image", + mediaType, + bytes: imageBase64FromDataUrl(dataUrl) + }); + } + return images; + } + function appendRawEvent(kind, value) { let row = null; if (kind === "snapshot") { @@ -1647,18 +1814,30 @@ def render_index_html(config: DebuggerConfig) -> str: const status = statusUpdate.status && typeof statusUpdate.status === "object" ? statusUpdate.status : {}; return { kind: "status_update", - taskId: statusUpdate.taskId || statusUpdate.task_id || "", - contextId: statusUpdate.contextId || statusUpdate.context_id || "", + taskId: ( + statusUpdate.deliveryTaskId || + statusUpdate.delivery_task_id || + statusUpdate.taskId || + statusUpdate.task_id || + "" + ), + contextId: ( + statusUpdate.deliveryContextId || + statusUpdate.delivery_context_id || + statusUpdate.contextId || + statusUpdate.context_id || + "" + ), state: status.state || "" }; } const task = payload.task && typeof payload.task === "object" ? payload.task : payload; - if (task.id || task.taskId || task.task_id) { + if (task.id || task.taskId || task.task_id || task.deliveryTaskId || task.delivery_task_id) { const status = task.status && typeof task.status === "object" ? task.status : {}; return { kind: "task_submitted", - taskId: task.id || task.taskId || task.task_id || "", - contextId: task.contextId || task.context_id || "", + taskId: task.deliveryTaskId || task.delivery_task_id || task.id || task.taskId || task.task_id || "", + contextId: task.deliveryContextId || task.delivery_context_id || task.contextId || task.context_id || "", state: status.state || "" }; } @@ -1672,11 +1851,31 @@ def render_index_html(config: DebuggerConfig) -> str: } function recordTaskIdentity(identity, role = "active") { - const taskId = String(identity && (identity.taskId || identity.task_id || identity.id) || ""); + const taskId = String( + identity && + ( + identity.deliveryTaskId || + identity.delivery_task_id || + identity.taskId || + identity.task_id || + identity.id + ) || + "" + ); if (!taskId) { return null; } - const contextId = String(identity && (identity.contextId || identity.context_id || state.contextId) || ""); + const contextId = String( + identity && + ( + identity.deliveryContextId || + identity.delivery_context_id || + identity.contextId || + identity.context_id || + state.contextId + ) || + "" + ); const taskState = String(identity && (identity.state || identity.status || "") || ""); const existing = state.taskHistory.find((item) => item.taskId === taskId); const next = { @@ -1726,6 +1925,14 @@ def render_index_html(config: DebuggerConfig) -> str: return stateValue === "TASK_STATE_SUBMITTED" || stateValue === "TASK_STATE_WORKING"; } + function isTerminalPipelineTaskState(value) { + const stateValue = String(value || "") + .toLowerCase() + .replace(/^task_state_/, "") + .replace(/-/g, "_"); + return ["canceled", "cancelled", "failed", "denied", "completed"].includes(stateValue); + } + function shouldKeepActiveTaskId(identity) { return identity && isWorkingA2ATaskState(identity.state); } @@ -1769,14 +1976,12 @@ def render_index_html(config: DebuggerConfig) -> str: if (activeTaskInput && state.activeTaskId && activeTaskInput.value.trim() !== state.activeTaskId) { activeTaskInput.value = state.activeTaskId; } - if (activeTaskInput && !state.activeTaskId && state.normalHandoffReady && activeTaskInput.value.trim()) { - activeTaskInput.value = ""; - } if ( activeTaskInput && !state.activeTaskId && - state.normalHandoffReady && - activeTaskInput.value.trim() === state.taskId + activeTaskInput.value.trim() && + (state.normalHandoffReady || + (isTerminalPipelineTaskState(state.status) && activeTaskInput.value.trim() === state.taskId)) ) { activeTaskInput.value = ""; } @@ -1815,10 +2020,15 @@ def render_index_html(config: DebuggerConfig) -> str: } function streamTaskIdForControls(controls) { - if (state.normalHandoffReady && !controls.activeTaskId) { + const activeTaskId = controls.activeTaskId || state.activeTaskId; + const pipelineTaskId = controls.taskId || state.taskId; + if (activeTaskId && !(isTerminalPipelineTaskState(state.status) && activeTaskId === pipelineTaskId)) { + return activeTaskId; + } + if (state.normalHandoffReady || isTerminalPipelineTaskState(state.status)) { return ""; } - return controls.activeTaskId || state.activeTaskId || controls.taskId || state.taskId; + return pipelineTaskId; } function cancelTaskIdForControls(controls) { @@ -2598,6 +2808,13 @@ def render_index_html(config: DebuggerConfig) -> str: if (type === "input_required") { return {label: "input required", text: summarizeValue(data), className: "timeline-permission"}; } + if (type === "pipeline_canceled") { + return { + label: "pipeline canceled", + text: data.reason || summarizeValue(data), + className: "timeline-canceled" + }; + } if (type.endsWith("_completed") && Object.prototype.hasOwnProperty.call(data, "conclusion")) { return { label: type.replace(/_/g, " "), @@ -2848,8 +3065,22 @@ def render_index_html(config: DebuggerConfig) -> str: } state.status = String(envelope.status || envelope.state || envelope.pipelineStatus || state.status || "running"); - state.taskId = String(envelope.taskId || envelope.task_id || state.taskId || ""); - state.contextId = String(envelope.contextId || envelope.context_id || state.contextId || ""); + state.taskId = String( + envelope.deliveryTaskId || + envelope.delivery_task_id || + envelope.taskId || + envelope.task_id || + state.taskId || + "" + ); + state.contextId = String( + envelope.deliveryContextId || + envelope.delivery_context_id || + envelope.contextId || + envelope.context_id || + state.contextId || + "" + ); if (state.taskId) { if (!state.normalHandoffReady && !state.activeTaskId) { state.activeTaskId = state.taskId; @@ -3340,7 +3571,8 @@ def render_index_html(config: DebuggerConfig) -> str: } function snapshotNormalHandoff(snapshot) { - return snapshotObject(snapshot && (snapshot.normalHandoff || snapshot.normal_handoff)); + const envelope = snapshotEnvelope(snapshot); + return snapshotObject(envelope && (envelope.normalHandoff || envelope.normal_handoff)); } function normalHandoffSummary(snapshot) { @@ -4021,6 +4253,7 @@ def render_index_html(config: DebuggerConfig) -> str: updateRawRequest(requestRow, {status: "ok", response: body}); appendRawEvent("sse", {type: "cancel", body}); applyPipelineEvent(body); + await fetchStateIfAvailable(); } catch (error) { updateRawRequest(requestRow, { status: "error", @@ -4034,6 +4267,7 @@ def render_index_html(config: DebuggerConfig) -> str: async function streamMessage() { const controls = readControls(); + const images = await readSelectedImages(); const payload = { serverUrl: controls.serverUrl, cwd: controls.cwd, @@ -4041,6 +4275,9 @@ def render_index_html(config: DebuggerConfig) -> str: taskId: streamTaskIdForControls(controls), prompt: controls.prompt }; + if (images.length) { + payload.images = images; + } const requestRow = appendRawEvent("request", {method: "POST", path: "/api/message/stream", payload}); state.streamsInFlight += 1; state.status = "streaming"; @@ -4100,6 +4337,9 @@ def render_index_html(config: DebuggerConfig) -> str: } catch { parsed = raw; } + if (parsed && typeof parsed === "object" && (parsed.type === "error" || parsed.error)) { + state.status = "error"; + } const rawRow = appendRawEvent("sse", parsed); if (rawRow) { applyPipelineEvent(parsed, rawRow, {alreadyRecorded: true}); @@ -4308,6 +4548,10 @@ def render_index_html(config: DebuggerConfig) -> str: element.readOnly = true; } }); + const imageInput = byId("image-input"); + if (imageInput) { + imageInput.disabled = true; + } ["health-button", "stream-button", "fetch-state-button", "cancel-button"].forEach((id) => { const button = byId(id); if (button) { @@ -4351,6 +4595,10 @@ def render_index_html(config: DebuggerConfig) -> str: element.setAttribute("readonly", "readonly"); } }); + const imageInput = clone.querySelector("#image-input"); + if (imageInput) { + imageInput.setAttribute("disabled", "disabled"); + } ["health-button", "stream-button", "fetch-state-button", "cancel-button"].forEach((id) => { const button = clone.querySelector(`#${cssEscape(id)}`); if (button) { @@ -4435,11 +4683,13 @@ def render_index_html(config: DebuggerConfig) -> str: byId("cancel-button").addEventListener("click", (event) => withButtonState(event.currentTarget, cancelTask)); byId("stream-button").addEventListener("click", (event) => withStreamAction(event.currentTarget, streamMessage)); byId("export-html-button").addEventListener("click", exportCurrentHtmlSnapshot); + byId("image-input").addEventListener("change", updateImageSummary); if (isExportMode) { restoreExportState(exportPayload); configureExportMode(); } + updateImageSummary(); renderPipeline(); renderRaw(); @@ -4595,8 +4845,8 @@ def _message_stream_body(body: dict[str, Any]) -> tuple[str, dict[str, Any]]: task_id = str(body.get("taskId", "")) if not cwd: raise ValueError("cwd is required") - if not prompt: - raise ValueError("prompt is required") + if not prompt and not body.get("images"): + raise ValueError("prompt or image is required") payload = build_message_stream_payload( cwd=cwd, prompt=prompt, @@ -4604,6 +4854,7 @@ def _message_stream_body(body: dict[str, Any]) -> tuple[str, dict[str, Any]]: task_id=task_id, request_id=str(uuid.uuid4()), message_id=str(uuid.uuid4()), + images=body.get("images"), ) return server_url, payload @@ -4634,6 +4885,58 @@ def _send_sse_error(handler: BaseHTTPRequestHandler, status: int, message: str) raise +def _send_sse_event(handler: BaseHTTPRequestHandler, status: int, event: dict[str, Any]) -> None: + body = f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode("utf-8") + try: + handler.send_response(status) + handler.send_header("Content-Type", "text/event-stream; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + except OSError as exc: + if _is_client_disconnect_error(exc): + return + raise + + +def _jsonrpc_error_message(value: Any) -> str | None: + if not isinstance(value, dict): + return None + error = value.get("error") + if isinstance(error, dict): + message = error.get("message") + recoverable_task_id = _recoverable_task_id_from_jsonrpc_error(error) + if isinstance(message, str) and message: + if recoverable_task_id and not _message_has_resume_guidance(message, recoverable_task_id): + return f"{message} Resume task {recoverable_task_id}." + return message + return json.dumps(error, ensure_ascii=False) + if isinstance(error, str) and error: + return error + return None + + +def _message_has_resume_guidance(message: str, task_id: str) -> bool: + return f"resume task {task_id}".casefold() in message.casefold() + + +def _recoverable_task_id_from_jsonrpc_error(error: dict[str, Any]) -> str | None: + data = error.get("data") + if isinstance(data, dict): + task_id = data.get("recoverableTaskId") + return task_id if isinstance(task_id, str) and task_id else None + if isinstance(data, list): + for item in data: + if not isinstance(item, dict): + continue + metadata = item.get("metadata") + if isinstance(metadata, dict): + task_id = metadata.get("recoverableTaskId") + if isinstance(task_id, str) and task_id: + return task_id + return None + + def create_server(config: DebuggerConfig) -> ThreadingHTTPServer: class A2APipelineDebuggerHandler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: object) -> None: @@ -4679,6 +4982,32 @@ def do_POST(self) -> None: server_url, payload = _message_stream_body(body) try: with _open_sse_stream(server_url, payload) as response: + content_type = str(response.headers.get("Content-Type", "")).lower() + if "text/event-stream" not in content_type: + raw = response.read() + data, _text = _decode_json_text(raw) + message = _jsonrpc_error_message(data) + if message: + event = { + "type": "error", + "error": message, + "statusCode": response.status, + "body": data, + } + append_debug_log(config, "sse", event) + _send_sse_event(self, 200, event) + return + append_debug_log( + config, + "error", + { + "ok": False, + "error": "Target server returned a non-SSE response", + "statusCode": response.status, + }, + ) + _send_sse_error(self, 502, "Target server returned a non-SSE response") + return self.send_response(response.status) self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.end_headers() @@ -4738,7 +5067,7 @@ def main(argv: list[str] | None = None) -> None: replay_export=load_debug_log_export(args.load_log_dir) if args.load_log_dir else None, ) server = create_server(config) - host, port = server.server_address + host, port = server.server_address[:2] print(f"A2A pipeline debugger listening on http://{host}:{port}", flush=True) print(f"A2A pipeline debugger logs: {config.log_dir}", flush=True) try: diff --git a/scripts/a2a/e2e/README.md b/scripts/a2a/e2e/README.md index 1e158d52..0898ba5d 100644 --- a/scripts/a2a/e2e/README.md +++ b/scripts/a2a/e2e/README.md @@ -60,6 +60,11 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \ --scenario scenario1 \ --scenario selection-waiting \ --scenario ask-waiting \ + --scenario image-initial \ + --scenario image-ask-waiting \ + --scenario image-selection-waiting \ + --scenario image-normal-handoff \ + --scenario image-interrupt \ --scenario step1-running \ --scenario step2-running \ --scenario step3-running \ @@ -75,12 +80,17 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \ --scenario rollback-step2 \ --scenario rollback-step3 \ --scenario rollback-step4 \ - --scenario rollback-step5 + --scenario rollback-step5 \ + --scenario rollback-step5-cleanup \ + --scenario rollback-step5-cleanup-recovery ``` Provider, tool, and cloud execution scenarios are guarded by default. Use `--allow-real-cloud` only when you intentionally want to run against real providers and Alibaba Cloud credentials. +The rollback step5 cleanup scenarios intentionally leave the second stack in +ROS as proof that cleanup only removed the rollback leftover; delete that stack +after you finish inspecting the run. ## What Each Scenario Covers @@ -88,11 +98,16 @@ providers and Alibaba Cloud credentials. not a separate runner or a special mode; it lives in the same scenario matrix as the rest of the tests. -| Scenario | Where the server is killed | Recovery input | Main assertion | +| Scenario | Cut point / special condition | Recovery input | Main assertion | | --- | --- | --- | --- | | `scenario1` | After pipeline completion and one normal-chat follow-up | Ask what the previous normal-chat question was | Normal-chat history survives restart; VSwitch evidence exists. | | `selection-waiting` | Step 4 waits for candidate selection | `你随便选一个方案。` without `taskId` | Waiting step4 task is recovered and selected; VSwitch evidence exists. | | `ask-waiting` | `ask_user_question` waits for user input | Clarification answers without `taskId` | Pending ask input is recovered and pipeline completes; VSwitch evidence exists. | +| `image-initial` | Initial user message is the static `initial.png` image fixture | Candidate selection text | The image starts the pipeline, reaches step4 selection, completes, and produces VSwitch evidence. | +| `image-ask-waiting` | `ask_user_question` waits for user input, then the server restarts | Static `ask-first-answer.png` / `ask-second-answer.png` image fixtures without `taskId` | Pending ask input is recovered, image answers hydrate the recovered task, and the pipeline completes with VSwitch evidence. | +| `image-selection-waiting` | Step 4 waits for candidate selection, then the server restarts | Static `selection.png` image fixture without `taskId` | Waiting step4 task is recovered, the image selection is accepted, and VSwitch evidence exists. | +| `image-normal-handoff` | Pipeline completes and hands off to normal chat; the normal follow-up is static `normal-followup.png`, then the server restarts | Normal-chat recovery question without `taskId` | Image follow-up stays in the same `contextId`, uses a new normal-chat task, and completed handoff state survives restart. | +| `image-interrupt` | Step 3 receives static `rollback-interrupt.png` as an image rollback to `intent_parsing`, then the server restarts | `继续`, plus selection when needed | The image interrupt is recognized, the pipeline completes as a security-group task, and final deployment evidence is not VSwitch. | | `step1-running` | `intent_parsing` running | `继续` | Running pipeline task is recovered and completes; VSwitch evidence exists. | | `step2-running` | `architecture_planning` running | `继续` | Running pipeline task is recovered and completes; VSwitch evidence exists. | | `step3-running` | `evaluate_candidates` candidate/sub-pipeline running | `继续` | Sub-pipeline state is recovered and completes; VSwitch evidence exists. | @@ -101,6 +116,8 @@ the rest of the tests. | `normal-running` | Normal-chat response streaming after pipeline handoff | `继续`, then history check | Normal-chat task recovery keeps same `contextId` history. | | `cancel-step1` ... `cancel-step5` | Active pipeline task is canceled at the named step | Normal-chat follow-up after cancel, then restart and history check | Canceled snapshot stays canceled; normal-chat history survives restart. | | `rollback-step1` ... `rollback-step5` | Step 3 receives rollback to `intent_parsing`, then the named post-rollback step is killed | `继续`, plus selection when needed | Post-rollback pipeline completes as a security-group task, not VSwitch. | +| `rollback-step5-cleanup` | First step5 stack is observed, then rollback creates a second stack and hands off to normal chat | A normal-chat follow-up triggers cleanup | First rollback stack reaches cleanup complete and is deleted in ROS; second stack remains. | +| `rollback-step5-cleanup-recovery` | Same as `rollback-step5-cleanup`, then the server is killed after cleanup starts | `继续` in normal chat after restart | Cleanup is triggered again after restart; first stack is deleted and second stack remains. | | `fault-after-snapshot` | Deterministic crash after A2A pipeline snapshot persistence | `继续`, plus selection when needed | `GetTask` / `ListTasks` expose the recovered task and the pipeline completes. | ## Representative Inputs @@ -136,6 +153,12 @@ Rollback scenarios interrupt step 3 with: 回退到 intent_parsing,选择一个已有vpc,创建一个安全组 ``` +Image scenarios send a small text prompt plus static PNG fixtures from +`scripts/a2a/e2e/fixtures/text-images/`. The fixture manifest pins the text, +file name, media type, byte size, and SHA-256 hash. A scenario run also writes +`image-fixtures/manifest.json`; fixed prompts should show `source: static`. +Only ad-hoc or CLI-overridden text falls back to runtime image rendering. + ## Recommended Order When stabilizing changes, run the smaller or more diagnostic cases first: @@ -144,10 +167,13 @@ When stabilizing changes, run the smaller or more diagnostic cases first: 2. `scenario1` 3. `selection-waiting` 4. `ask-waiting` -5. `step1-running` through `step5-running` -6. `normal-running` -7. `cancel-step1` through `cancel-step5` -8. `rollback-step1` through `rollback-step5` +5. `image-initial`, `image-ask-waiting`, and `image-selection-waiting` +6. `image-normal-handoff` and `image-interrupt` +7. `step1-running` through `step5-running` +8. `normal-running` +9. `cancel-step1` through `cancel-step5` +10. `rollback-step1` through `rollback-step5` +11. `rollback-step5-cleanup`, then `rollback-step5-cleanup-recovery` ## Preflight @@ -224,6 +250,7 @@ Important files: - `*.task-get.json` and `*.task-list.json`: redacted `GetTask` / `ListTasks` artifacts when captured by the scenario. - `server-1.*.log` and `server-2.*.log`: server logs before and after restart. - `a2a-server.yml`: generated server config. +- `image-fixtures/manifest.json`: image input fixture usage for image scenarios, including whether each image came from a static repository fixture or runtime rendering. - `workspace/`: default A2A metadata cwd and generated tool outputs unless `--cwd` is provided. - `preflight.json`: provider preflight result unless `--skip-preflight` is used. diff --git a/scripts/a2a/e2e/README.zh-CN.md b/scripts/a2a/e2e/README.zh-CN.md index 76aa4727..0fa7c431 100644 --- a/scripts/a2a/e2e/README.zh-CN.md +++ b/scripts/a2a/e2e/README.zh-CN.md @@ -56,6 +56,11 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \ --scenario scenario1 \ --scenario selection-waiting \ --scenario ask-waiting \ + --scenario image-initial \ + --scenario image-ask-waiting \ + --scenario image-selection-waiting \ + --scenario image-normal-handoff \ + --scenario image-interrupt \ --scenario step1-running \ --scenario step2-running \ --scenario step3-running \ @@ -71,22 +76,31 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \ --scenario rollback-step2 \ --scenario rollback-step3 \ --scenario rollback-step4 \ - --scenario rollback-step5 + --scenario rollback-step5 \ + --scenario rollback-step5-cleanup \ + --scenario rollback-step5-cleanup-recovery ``` provider、tool、真实云调用场景默认会被保护住。只有确认要使用真实 provider 和阿里云凭证 时,才加 `--allow-real-cloud`。 +`rollback-step5-cleanup` 这两个场景会故意保留第 2 个 stack,作为“只清理回滚残留”的验收 +证据;检查完 run 产物后请再手工或通过后续流程删除它。 ## 每个场景覆盖什么 `scenario1` 是历史遗留名称,表示“pipeline 完成后恢复 normal chat”的基线场景。它不是 单独 runner,也不是特殊模式,而是完整场景矩阵中的一个场景。 -| 场景 | kill server 的位置 | 恢复时输入 | 主要验收 | +| 场景 | 切点 / 特殊条件 | 恢复时输入 | 主要验收 | | --- | --- | --- | --- | | `scenario1` | pipeline 完成并完成一轮 normal-chat follow-up 后 | 询问上一条 normal-chat 问题是什么 | normal-chat 历史重启后仍可用;存在 VSwitch 证据。 | | `selection-waiting` | step4 等待候选方案选择时 | 不带 `taskId` 发送 `你随便选一个方案。` | 能恢复等待中的 step4 task 并完成选择;存在 VSwitch 证据。 | | `ask-waiting` | `ask_user_question` 等待用户输入时 | 不带 `taskId` 发送澄清回答 | 能恢复 pending ask 输入并完成 pipeline;存在 VSwitch 证据。 | +| `image-initial` | 首轮用户消息就是静态 `initial.png` 图片 fixture | 文本选择候选方案 | 图片能启动 pipeline,进入 step4 选择,最终完成并产生 VSwitch 证据。 | +| `image-ask-waiting` | `ask_user_question` 等待用户输入,随后重启 server | 不带 `taskId` 发送静态 `ask-first-answer.png` / `ask-second-answer.png` 图片 fixture | pending ask 输入能恢复,图片回答能 hydrate 到恢复后的 task,最终完成并产生 VSwitch 证据。 | +| `image-selection-waiting` | step4 等待候选方案选择,随后重启 server | 不带 `taskId` 发送静态 `selection.png` 图片 fixture | 能恢复等待中的 step4 task,图片选择被接受,并产生 VSwitch 证据。 | +| `image-normal-handoff` | pipeline 完成并 handoff 到 normal chat;normal follow-up 是静态 `normal-followup.png`,随后重启 server | 不带 `taskId` 发送 normal-chat 恢复问题 | 图片 follow-up 保持同一个 `contextId`,使用新的 normal-chat task;completed handoff 状态重启后仍可恢复。 | +| `image-interrupt` | step3 收到静态 `rollback-interrupt.png` 图片,表示回滚到 `intent_parsing`,随后重启 server | `继续`,必要时再选择方案 | 图片 interrupt 能被识别;pipeline 以安全组任务完成,最终部署证据不是 VSwitch。 | | `step1-running` | `intent_parsing` 运行中 | `继续` | running pipeline task 能恢复并完成;存在 VSwitch 证据。 | | `step2-running` | `architecture_planning` 运行中 | `继续` | running pipeline task 能恢复并完成;存在 VSwitch 证据。 | | `step3-running` | `evaluate_candidates` 的 candidate/sub-pipeline 运行中 | `继续` | sub-pipeline 状态能恢复并完成;存在 VSwitch 证据。 | @@ -95,6 +109,8 @@ provider、tool、真实云调用场景默认会被保护住。只有确认要 | `normal-running` | pipeline handoff 后的 normal-chat 响应流式输出中 | `继续`,随后检查历史 | normal-chat task 恢复后仍保持同一个 `contextId` 历史。 | | `cancel-step1` ... `cancel-step5` | 在指定 step cancel 活跃 pipeline task | cancel 后 normal-chat follow-up,重启后检查历史 | canceled snapshot 保持 canceled;normal-chat 历史重启后仍可用。 | | `rollback-step1` ... `rollback-step5` | step3 收到回滚到 `intent_parsing`,随后在回滚后的指定 step kill | `继续`,必要时再选择方案 | 回滚后的 pipeline 以安全组任务完成,不再是 VSwitch。 | +| `rollback-step5-cleanup` | 第一次 step5 stack 已被观测后触发回滚,随后第二次 step5 创建新 stack 并进入 normal chat | normal-chat follow-up 触发 cleanup | 第 1 个回滚残留 stack 在 cleanup snapshot 中完成,且 ROS 中已删除;第 2 个 stack 仍保留。 | +| `rollback-step5-cleanup-recovery` | 基于 `rollback-step5-cleanup`,在第 1 个 stack cleanup 开始后 kill server | 重启后在 normal chat 发送 `继续` | 恢复后重新触发 cleanup;第 1 个 stack 被删除,第 2 个 stack 仍保留。 | | `fault-after-snapshot` | A2A pipeline snapshot 持久化后确定性 crash | `继续`,必要时再选择方案 | `GetTask` / `ListTasks` 能看到恢复 task,pipeline 能完成。 | ## 代表输入 @@ -129,6 +145,12 @@ rollback 场景会在 step3 发送: 回退到 intent_parsing,选择一个已有vpc,创建一个安全组 ``` +图片场景会发送一个很短的读图提示词,并附带 +`scripts/a2a/e2e/fixtures/text-images/` 下的静态 PNG fixture。fixture manifest 会固化 +文本、文件名、媒体类型、字节数和 SHA-256。每次场景运行还会写 +`image-fixtures/manifest.json`;固定 prompt 应显示 `source: static`。只有临时输入或通过 +CLI 覆盖后的文本,才会回退到运行时渲染图片。 + ## 推荐执行顺序 稳定或回归时,建议从更小、更容易定位问题的场景开始: @@ -137,10 +159,13 @@ rollback 场景会在 step3 发送: 2. `scenario1` 3. `selection-waiting` 4. `ask-waiting` -5. `step1-running` 到 `step5-running` -6. `normal-running` -7. `cancel-step1` 到 `cancel-step5` -8. `rollback-step1` 到 `rollback-step5` +5. `image-initial`、`image-ask-waiting` 和 `image-selection-waiting` +6. `image-normal-handoff` 和 `image-interrupt` +7. `step1-running` 到 `step5-running` +8. `normal-running` +9. `cancel-step1` 到 `cancel-step5` +10. `rollback-step1` 到 `rollback-step5` +11. `rollback-step5-cleanup`,再跑 `rollback-step5-cleanup-recovery` ## Preflight @@ -215,6 +240,7 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \ - `*.task-get.json` 和 `*.task-list.json`:场景捕获到的、经过脱敏的 `GetTask` / `ListTasks` artifact。 - `server-1.*.log` 和 `server-2.*.log`:重启前后的 server 日志。 - `a2a-server.yml`:生成的 server 配置。 +- `image-fixtures/manifest.json`:图片场景的图片输入 fixture 使用情况,包括每张图来自仓库静态 fixture 还是运行时渲染。 - `workspace/`:默认 A2A metadata cwd;除非指定 `--cwd`,工具输出和生成模板会写到这里。 - `preflight.json`:provider preflight 结果;使用 `--skip-preflight` 时不会生成。 diff --git a/scripts/a2a/e2e/common.py b/scripts/a2a/e2e/common.py index f881daf9..5438e06b 100644 --- a/scripts/a2a/e2e/common.py +++ b/scripts/a2a/e2e/common.py @@ -172,6 +172,7 @@ def stream_message( timeout: float, context_id: str = "", task_id: str = "", + images: list[dict[str, Any]] | None = None, redaction_env: dict[str, str] | None = None, ) -> StreamSummary: payload = build_message_stream_payload( @@ -181,6 +182,7 @@ def stream_message( task_id=task_id, request_id=str(uuid.uuid4()), message_id=str(uuid.uuid4()), + images=images, ) _append_jsonl(run_dir / "requests.jsonl", {"name": name, "payload": payload, "at": _utc_now()}, redaction_env) request = Request( @@ -253,14 +255,16 @@ def run_llm_preflight( } except subprocess.TimeoutExpired as exc: elapsed = time.monotonic() - started - output = _redact_sensitive_text("\n".join(part for part in [exc.stdout, exc.stderr] if part), preflight_env) + stdout = _subprocess_output_text(exc.stdout) + stderr = _subprocess_output_text(exc.stderr) + output = _redact_sensitive_text("\n".join(part for part in [stdout, stderr] if part), preflight_env) payload = { "ok": False, "returnCode": None, "elapsedSeconds": round(elapsed, 3), "summary": f"timed out after {timeout:.0f}s" + (f": {_compact_text(output)}" if output else ""), - "stdout": _redact_sensitive_text(exc.stdout or "", preflight_env), - "stderr": _redact_sensitive_text(exc.stderr or "", preflight_env), + "stdout": _redact_sensitive_text(stdout, preflight_env), + "stderr": _redact_sensitive_text(stderr, preflight_env), } _write_json(run_dir / "preflight.json", payload) return payload @@ -452,6 +456,14 @@ def _split_python_command(value: str) -> list[str]: return parts +def _subprocess_output_text(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + def _redact_sensitive_text(text: str, env: dict[str, str] | None) -> str: redacted = text for name, value in (env or {}).items(): diff --git a/scripts/a2a/e2e/fixtures/text-images/ask-first-answer.png b/scripts/a2a/e2e/fixtures/text-images/ask-first-answer.png new file mode 100644 index 00000000..92c5a9e1 Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/ask-first-answer.png differ diff --git a/scripts/a2a/e2e/fixtures/text-images/ask-second-answer.png b/scripts/a2a/e2e/fixtures/text-images/ask-second-answer.png new file mode 100644 index 00000000..c776f80e Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/ask-second-answer.png differ diff --git a/scripts/a2a/e2e/fixtures/text-images/initial.png b/scripts/a2a/e2e/fixtures/text-images/initial.png new file mode 100644 index 00000000..a4fae552 Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/initial.png differ diff --git a/scripts/a2a/e2e/fixtures/text-images/manifest.json b/scripts/a2a/e2e/fixtures/text-images/manifest.json new file mode 100644 index 00000000..d8586e39 --- /dev/null +++ b/scripts/a2a/e2e/fixtures/text-images/manifest.json @@ -0,0 +1,44 @@ +{ + "initial": { + "filename": "initial.png", + "text": "选择一个已有vpc,创建一个vswitch", + "mediaType": "image/png", + "byteSize": 12697, + "sha256": "2f773773c5b528cb7fdafde969d464b19d4d3022c1e6f2ad85b162f98f7ff82e" + }, + "selection": { + "filename": "selection.png", + "text": "你随便选一个方案。", + "mediaType": "image/png", + "byteSize": 9907, + "sha256": "3aa92a48eed5c37115f18dc89a058ebbb06ac5eeea59f2870d4d58b355ac924b" + }, + "normal-followup": { + "filename": "normal-followup.png", + "text": "你刚才创建了什么", + "mediaType": "image/png", + "byteSize": 8684, + "sha256": "03a9b1006f840c0bb8ef4f2bd75819033489f4e4f14d92abd7e12b591dd9c26d" + }, + "ask-first-answer": { + "filename": "ask-first-answer.png", + "text": "我要创建云网络资源;本次只选择已有 VPC 创建一个 VSwitch,不部署 ECS、EIP、SLB 或 Nginx。", + "mediaType": "image/png", + "byteSize": 35073, + "sha256": "499fd5648baafe7d9904259a1eb3408b65556a39a778b2a2a89b89058bcd61b0" + }, + "ask-second-answer": { + "filename": "ask-second-answer.png", + "text": "选择一个已有 VPC,创建一个 VSwitch;地域、可用区和网段你按低成本默认值推荐。", + "mediaType": "image/png", + "byteSize": 32302, + "sha256": "0fdb1c4d4fce2038f9e5a4107ba8a7e96658a3ca2c705c834c4c93ca23b93dc5" + }, + "rollback-interrupt": { + "filename": "rollback-interrupt.png", + "text": "回退到 intent_parsing,选择一个已有vpc,创建一个安全组", + "mediaType": "image/png", + "byteSize": 20967, + "sha256": "1dfa25bba58757704b27a7ee8f44a42f4e69045730bba5320986194c873a5937" + } +} diff --git a/scripts/a2a/e2e/fixtures/text-images/normal-followup.png b/scripts/a2a/e2e/fixtures/text-images/normal-followup.png new file mode 100644 index 00000000..2f9864a7 Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/normal-followup.png differ diff --git a/scripts/a2a/e2e/fixtures/text-images/rollback-interrupt.png b/scripts/a2a/e2e/fixtures/text-images/rollback-interrupt.png new file mode 100644 index 00000000..50512a84 Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/rollback-interrupt.png differ diff --git a/scripts/a2a/e2e/fixtures/text-images/selection.png b/scripts/a2a/e2e/fixtures/text-images/selection.png new file mode 100644 index 00000000..cf8f0407 Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/selection.png differ diff --git a/scripts/a2a/e2e/run_recovery_scenarios.py b/scripts/a2a/e2e/run_recovery_scenarios.py index 52bea6b6..82c782d3 100644 --- a/scripts/a2a/e2e/run_recovery_scenarios.py +++ b/scripts/a2a/e2e/run_recovery_scenarios.py @@ -11,6 +11,9 @@ from __future__ import annotations import argparse +import base64 +import hashlib +import io import json import os import signal @@ -26,6 +29,9 @@ from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +import yaml +from PIL import Image, ImageDraw, ImageFont + E2E_SCRIPTS_DIR = Path(__file__).resolve().parent A2A_SCRIPTS_DIR = E2E_SCRIPTS_DIR.parent for scripts_dir in (E2E_SCRIPTS_DIR, A2A_SCRIPTS_DIR): @@ -76,10 +82,35 @@ INTERVENING_ASK_ANSWER = "使用默认配置(可用区和网段自动规划),继续。" ROLLBACK_PROMPT = "回退到 intent_parsing,选择一个已有vpc,创建一个安全组" CONTINUE_PROMPT = "继续" +CLEANUP_RECOVERY_PROMPT = ( + "请只回复“OK,继续”。不要调用任何工具,不要查询任何云资源,不要删除任何资源。" + "如果系统有后台 cleanup 恢复流程,请让它自行完成。" +) +CLEANUP_PROMPT_METADATA_TYPE = "pipeline_cleanup_prompt" +CLEANUP_EVENT_TYPES = frozenset( + { + "cleanup_started", + "cleanup_progress", + "cleanup_completed", + "cleanup_failed", + } +) +CLEANUP_ACTIVE_STATUSES = frozenset({"pending", "started", "in_progress", "failed"}) +IMAGE_TEXT_PROMPT = "请读取图片中的文字,并将图片中的文字作为本轮用户输入执行。" +STATIC_TEXT_IMAGE_FIXTURE_ROOT = E2E_SCRIPTS_DIR / "fixtures" / "text-images" +STATIC_TEXT_IMAGE_FIXTURES = { + "initial": DEFAULT_INITIAL_PROMPT, + "selection": DEFAULT_SELECTION_PROMPT, + "normal-followup": DEFAULT_NORMAL_FOLLOWUP_PROMPT, + "ask-first-answer": ASK_FIRST_ANSWER, + "ask-second-answer": ASK_SECOND_ANSWER, + "rollback-interrupt": ROLLBACK_PROMPT, +} VSWITCH_MARKERS = ("ALIYUN::ECS::VSwitch", "VSwitchId", "vsw-", "VSwitch", "交换机") SECURITY_GROUP_MARKERS = ("ALIYUN::ECS::SecurityGroup", "SecurityGroupId", "sg-", "安全组") TERMINAL_STATES = {"TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_INPUT_REQUIRED"} +ROS_STACK_DELETED_STATUSES = {"DELETE_COMPLETE"} @dataclass @@ -102,6 +133,127 @@ class EventMatch: summary: StreamSummary +class TextImageFixtureStore: + def __init__(self, root: Path, static_root: Path = STATIC_TEXT_IMAGE_FIXTURE_ROOT) -> None: + self.root = root + self.root.mkdir(parents=True, exist_ok=True) + self.manifest_path = self.root / "manifest.json" + self.static_root = static_root + + def part(self, key: str, text: str) -> dict[str, Any]: + safe_key = _safe_fixture_key(key) + path = self._static_fixture_path(safe_key, text) + source = "static" + if path is None: + path = self.root / f"{safe_key}.png" + source = "generated" + if not path.exists(): + path.write_bytes(_render_text_png(text)) + raw = path.read_bytes() + self._record_manifest(safe_key, text=text, path=path, byte_size=len(raw), source=source) + return { + "filename": path.name, + "mediaType": "image/png", + "bytes": base64.b64encode(raw).decode("ascii"), + } + + def _static_fixture_path(self, key: str, text: str) -> Path | None: + try: + manifest = json.loads((self.static_root / "manifest.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(manifest, dict): + return None + entry = manifest.get(key) + if not isinstance(entry, dict) or entry.get("text") != text or entry.get("mediaType") != "image/png": + return None + filename = entry.get("filename") + if not isinstance(filename, str) or not filename: + return None + path = self.static_root / filename + return path if path.is_file() else None + + def _record_manifest(self, key: str, *, text: str, path: Path, byte_size: int, source: str) -> None: + try: + manifest = json.loads(self.manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + manifest = {} + if not isinstance(manifest, dict): + manifest = {} + manifest[key] = { + "text": text, + "path": str(path), + "mediaType": "image/png", + "byteSize": byte_size, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "source": source, + } + _write_json(self.manifest_path, manifest) + + +def _safe_fixture_key(value: str) -> str: + safe = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in value.strip().lower()) + return safe.strip("-") or "input" + + +def _render_text_png(text: str) -> bytes: + font = _load_text_image_font(size=34) + lines = _wrap_text_for_image(text) + padding = 40 + line_spacing = 12 + probe = Image.new("RGB", (1, 1), "white") + draw = ImageDraw.Draw(probe) + boxes = [draw.textbbox((0, 0), line, font=font) for line in lines] + text_width = int(max((right - left for left, _top, right, _bottom in boxes), default=360)) + line_heights = [int(bottom - top) for _left, top, _right, bottom in boxes] or [40] + width = int(max(760, min(1600, text_width + padding * 2))) + height = int(max(220, sum(line_heights) + line_spacing * max(0, len(lines) - 1) + padding * 2)) + image = Image.new("RGB", (width, height), "white") + draw = ImageDraw.Draw(image) + y = padding + for line, line_height in zip(lines, line_heights, strict=False): + draw.text((padding, y), line, fill=(16, 24, 39), font=font) + y += line_height + line_spacing + output = io.BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + +def _wrap_text_for_image(text: str, *, max_chars: int = 26) -> list[str]: + lines: list[str] = [] + for raw_line in text.splitlines() or [text]: + line = raw_line.strip() + if not line: + lines.append("") + continue + while len(line) > max_chars: + lines.append(line[:max_chars]) + line = line[max_chars:] + if line: + lines.append(line) + return lines or [""] + + +def _load_text_image_font(*, size: int) -> Any: + candidates = [ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "/System/Library/Fonts/STHeiti Light.ttc", + "/Library/Fonts/Arial Unicode.ttf", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ] + for candidate in candidates: + if Path(candidate).is_file(): + try: + return ImageFont.truetype(candidate, size=size) + except OSError: + continue + return ImageFont.load_default() + + class BackgroundStream: def __init__( self, @@ -114,6 +266,7 @@ def __init__( timeout: float, context_id: str = "", task_id: str = "", + images: list[dict[str, Any]] | None = None, redaction_env: dict[str, str] | None = None, ) -> None: self.server_url = server_url @@ -124,6 +277,7 @@ def __init__( self.timeout = timeout self.context_id = context_id self.task_id = task_id + self.images = images self.redaction_env = redaction_env self.summary = StreamSummary(name=name, prompt=prompt, request_task_id=task_id) self.events: list[Any] = [] @@ -182,6 +336,7 @@ def _run(self) -> None: task_id=self.task_id, request_id=str(uuid.uuid4()), message_id=str(uuid.uuid4()), + images=self.images, ) _append_jsonl( self.run_dir / "requests.jsonl", @@ -230,6 +385,7 @@ def __init__(self, args: argparse.Namespace, *, scenario: str) -> None: self.server_cwd = str(Path(args.server_cwd).expanduser().resolve()) self.run_dir = _scenario_run_dir(args, scenario) self.run_dir.mkdir(parents=True, exist_ok=True) + self.image_fixtures = TextImageFixtureStore(self.run_dir / "image-fixtures") self.workspace_dir = Path(args.cwd).expanduser().resolve() if args.cwd else self.run_dir / "workspace" self.workspace_dir.mkdir(parents=True, exist_ok=True) self.cwd = str(self.workspace_dir) @@ -314,6 +470,7 @@ def stream( name: str, context_id: str | None = None, task_id: str | None = None, + images: list[dict[str, Any]] | None = None, ) -> StreamSummary: summary = stream_message( server_url=self.server_url, @@ -324,12 +481,31 @@ def stream( name=name, run_dir=self.run_dir, timeout=self.args.stream_timeout, + images=images, redaction_env=self.server_env, ) self._remember_identity(summary) self.summaries[name] = summary return summary + def stream_image_text( + self, + *, + text: str, + image_key: str, + name: str, + context_id: str | None = None, + task_id: str | None = None, + prompt: str = IMAGE_TEXT_PROMPT, + ) -> StreamSummary: + return self.stream( + prompt=prompt, + name=name, + context_id=context_id, + task_id=task_id, + images=[self.image_fixtures.part(image_key, text)], + ) + def start_stream( self, *, @@ -337,6 +513,7 @@ def start_stream( name: str, context_id: str | None = None, task_id: str | None = None, + images: list[dict[str, Any]] | None = None, ) -> BackgroundStream: stream = BackgroundStream( server_url=self.server_url, @@ -347,6 +524,7 @@ def start_stream( name=name, run_dir=self.run_dir, timeout=self.args.stream_timeout, + images=images, redaction_env=self.server_env, ) stream.start() @@ -359,6 +537,24 @@ def start_stream( self.summaries[name] = stream.summary return stream + def start_stream_image_text( + self, + *, + text: str, + image_key: str, + name: str, + context_id: str | None = None, + task_id: str | None = None, + prompt: str = IMAGE_TEXT_PROMPT, + ) -> BackgroundStream: + return self.start_stream( + prompt=prompt, + name=name, + context_id=context_id, + task_id=task_id, + images=[self.image_fixtures.part(image_key, text)], + ) + def fetch_state(self, name: str) -> Any: snapshot = fetch_pipeline_state( server_url=self.server_url, @@ -573,6 +769,9 @@ def callback(h: ScenarioHarness) -> None: context_id=h.context_id, task_id=h.pipeline_task_id, ) + h.checks["after-pipeline state has no cleanup activity"] = not _snapshot_has_cleanup_activity( + h.snapshots["after_pipeline"] + ) normal = h.stream(prompt=args.normal_followup_prompt, name="03-normal-followup", task_id="") h.checks["normal follow-up stayed in same context"] = normal.context_id == h.context_id h.checks["normal follow-up used a new task"] = bool(normal.task_id) and normal.task_id != h.pipeline_task_id @@ -587,6 +786,9 @@ def callback(h: ScenarioHarness) -> None: context_id=h.context_id, task_id=h.pipeline_task_id, ) + h.checks["after-restart state has no cleanup activity"] = not _snapshot_has_cleanup_activity( + h.snapshots["after_restart"] + ) recovery = h.stream(prompt=args.recovery_prompt, name="04-recovery-question", task_id="") h.checks["recovery stayed in same context"] = recovery.context_id == h.context_id h.checks["recovery used a new task"] = bool(recovery.task_id) and recovery.task_id not in { @@ -596,6 +798,9 @@ def callback(h: ScenarioHarness) -> None: h.checks["recovery finished turn"] = _normal_turn_finished(recovery) h.checks["recovery answer mentions previous question"] = args.expected_text in recovery.text h.checks["VSwitch evidence found"] = _has_any_marker(_all_evidence(h), VSWITCH_MARKERS) + h.checks["scenario1 emitted no cleanup events"] = not _run_dir_has_cleanup_events(h.run_dir) + h.checks["scenario1 persisted no cleanup prompt"] = not _session_has_cleanup_prompt(h) + h.checks["scenario1 ledger has no cleanup-required resources"] = not _cleanup_ledger_has_required_resources(h) return _run_with_harness(args, scenario, callback) @@ -704,6 +909,164 @@ def callback(h: ScenarioHarness) -> None: return _run_with_harness(args, scenario, callback) +def run_image_initial(args: argparse.Namespace, scenario: str) -> int: + def callback(h: ScenarioHarness) -> None: + initial = h.stream_image_text( + text=args.initial_prompt, + image_key="initial", + name="01-initial-image", + context_id="", + task_id="", + ) + initial = _answer_intervening_ask_inputs(h, initial, name_prefix="01-initial-image") + h.checks["image initial reached step4 input_required"] = ( + initial.last_input_required_step_id == "confirm_and_select" + ) + selection = h.stream(prompt=args.selection_prompt, name="02-select-candidate") + h.checks["image initial selection completed pipeline"] = _pipeline_completed(selection) + h.checks["image initial VSwitch evidence found"] = _has_any_marker(_all_evidence(h), VSWITCH_MARKERS) + + return _run_with_harness(args, scenario, callback) + + +def run_image_ask_waiting(args: argparse.Namespace, scenario: str) -> int: + def callback(h: ScenarioHarness) -> None: + initial = h.stream(prompt=ASK_TRIGGER_PROMPT, name="01-ask-trigger", context_id="", task_id="") + h.checks["initial reached input_required"] = _reached_input_required(initial) + h.checks["input_required is ask_user_question"] = ( + _latest_pending_kind(h.run_dir / "01-ask-trigger.events.jsonl") == "ask_user_question" + ) + h.kill9_and_restart() + snapshot = h.fetch_state("after-restart") + h.checks["snapshot still waiting input"] = _snapshot_value(snapshot, "status") == "waiting_input" + h.checks["pending input is ask_user_question"] = _pending_kind(snapshot) == "ask_user_question" + answer = h.stream_image_text( + text=ASK_FIRST_ANSWER, + image_key="ask-first-answer", + name="02-answer-first-ask-image", + task_id="", + ) + _add_hydrated_task_checks(h, answer, "first ask image answer") + final_summary = answer + if answer.last_input_required_step_id: + second = h.stream_image_text( + text=ASK_SECOND_ANSWER, + image_key="ask-second-answer", + name="03-answer-second-ask-image", + ) + _add_same_task_checks(h, second, "second ask image answer") + _finish_pipeline_after_possible_input(h, second, args) + final_summary = second + else: + _finish_pipeline_after_possible_input(h, answer, args) + h.checks["pipeline completed after ask image recovery"] = _completed_snapshot_or_stream(h, final_summary) + h.checks["VSwitch evidence found"] = _has_any_marker(_all_evidence(h), VSWITCH_MARKERS) + + return _run_with_harness(args, scenario, callback) + + +def run_image_selection_waiting(args: argparse.Namespace, scenario: str) -> int: + def callback(h: ScenarioHarness) -> None: + initial = h.stream(prompt=args.initial_prompt, name="01-initial", context_id="", task_id="") + initial = _answer_intervening_ask_inputs(h, initial, name_prefix="01-initial") + h.checks["initial reached step4 input_required"] = initial.last_input_required_step_id == "confirm_and_select" + h.kill9_and_restart() + snapshot = h.fetch_state("after-restart") + h.checks["snapshot still waiting input"] = _snapshot_value(snapshot, "status") == "waiting_input" + h.checks["pending input is confirm_and_select"] = _pending_step_id(snapshot) == "confirm_and_select" + selection = h.stream_image_text( + text=args.selection_prompt, + image_key="selection", + name="02-select-after-restart-image", + task_id="", + ) + _add_hydrated_task_checks(h, selection, "selection image answer") + h.checks["selection image completed pipeline"] = _pipeline_completed(selection) + h.checks["VSwitch evidence found"] = _has_any_marker(_all_evidence(h), VSWITCH_MARKERS) + + return _run_with_harness(args, scenario, callback) + + +def run_image_normal_handoff(args: argparse.Namespace, scenario: str) -> int: + def callback(h: ScenarioHarness) -> None: + _complete_pipeline(h, args) + normal = h.stream_image_text( + text=args.normal_followup_prompt, + image_key="normal-followup", + name="03-normal-followup-image", + task_id="", + ) + h.checks["normal image follow-up stayed in same context"] = normal.context_id == h.context_id + h.checks["normal image follow-up used a new task"] = ( + bool(normal.task_id) and normal.task_id != h.pipeline_task_id + ) + h.checks["normal image follow-up finished turn"] = _normal_turn_finished(normal) + h.checks["normal image follow-up produced text"] = bool(normal.text.strip()) + h.kill9_and_restart() + h.snapshots["after_restart"] = h.fetch_state("after-restart") + _add_completed_snapshot_checks( + h.checks, + "after-restart state", + h.snapshots["after_restart"], + context_id=h.context_id, + task_id=h.pipeline_task_id, + ) + recovery = h.stream(prompt=args.recovery_prompt, name="04-recovery-question", task_id="") + h.checks["normal image recovery stayed in same context"] = recovery.context_id == h.context_id + h.checks["normal image recovery finished turn"] = _normal_turn_finished(recovery) + + return _run_with_harness(args, scenario, callback) + + +def run_image_interrupt(args: argparse.Namespace, scenario: str) -> int: + def callback(h: ScenarioHarness) -> None: + initial = h.start_stream(prompt=args.initial_prompt, name="01-initial-running", context_id="", task_id="") + observed_streams = _wait_for_with_intervening_ask_inputs( + h, + [initial], + _candidate_started, + description="candidate started before image interrupt", + timeout=args.event_timeout, + name_prefix="initial-running", + ) + rollback = h.start_stream_image_text( + text=ROLLBACK_PROMPT, + image_key="rollback-interrupt", + name="02-rollback-image-interrupt", + ) + _wait_any( + [*observed_streams, rollback], + _event_type("rollback_completed"), + description="image rollback_completed", + timeout=args.event_timeout, + ) + streams_to_join = [*observed_streams, rollback] + _wait_any( + [*observed_streams, rollback], + _step_started("intent_parsing"), + description="post-image-rollback step_started(intent_parsing)", + timeout=args.event_timeout, + ) + h.fetch_state("before-kill") + h.kill9_and_restart() + for stream in streams_to_join: + _join_after_kill(stream, h) + snapshot = h.fetch_state("after-restart") + h.checks["state endpoint returned snapshot after image interrupt restart"] = _snapshot(snapshot) is not None + resumed = h.stream(prompt=CONTINUE_PROMPT, name="03-continue-after-restart") + _finish_pipeline_after_possible_input(h, resumed, args) + h.checks["pipeline completed after image interrupt recovery"] = _completed_snapshot_or_stream(h, resumed) + final_state = h.fetch_state("after-image-interrupt-completion") + final_deploying = _final_deployment_evidence(final_state) + h.checks["final deploying target is security group"] = _has_any_marker( + final_deploying, + SECURITY_GROUP_MARKERS, + ) + h.checks["final deploying target is not VSwitch"] = not _has_any_marker(final_deploying, VSWITCH_MARKERS) + + return _run_with_harness(args, scenario, callback) + + def run_rollback(args: argparse.Namespace, scenario: str) -> int: target_step = _ROLLBACK_SCENARIOS[scenario] @@ -868,11 +1231,14 @@ def callback(h: ScenarioHarness) -> None: _add_hydrated_task_checks(h, resumed, "continue") _finish_pipeline_after_possible_input(h, resumed, args) after_continue = h.capture_task_snapshots("after-continue") - h.checks["task_get_after_continue_completed"] = _task_response_matches( - after_continue["task_get"], - task_id=h.pipeline_task_id, - context_id=h.context_id, - ) and _task_status_state(after_continue["task_get"]) == "TASK_STATE_COMPLETED" + h.checks["task_get_after_continue_completed"] = ( + _task_response_matches( + after_continue["task_get"], + task_id=h.pipeline_task_id, + context_id=h.context_id, + ) + and _task_status_state(after_continue["task_get"]) == "TASK_STATE_COMPLETED" + ) h.checks["task_list_after_continue_kept_recovered_task"] = _task_list_contains( after_continue["task_list"], task_id=h.pipeline_task_id, @@ -884,6 +1250,138 @@ def callback(h: ScenarioHarness) -> None: return _run_with_harness(args, scenario, callback) +def run_rollback_step5_cleanup(args: argparse.Namespace, scenario: str) -> int: + return _run_rollback_step5_cleanup(args, scenario, kill_during_cleanup=False) + + +def run_rollback_step5_cleanup_recovery(args: argparse.Namespace, scenario: str) -> int: + return _run_rollback_step5_cleanup(args, scenario, kill_during_cleanup=True) + + +def _run_rollback_step5_cleanup( + args: argparse.Namespace, + scenario: str, + *, + kill_during_cleanup: bool, +) -> int: + def callback(h: ScenarioHarness) -> None: + initial = h.stream(prompt=args.initial_prompt, name="01-initial", context_id="", task_id="") + initial = _answer_intervening_ask_inputs(h, initial, name_prefix="01-initial") + h.checks["initial reached step4 selection"] = initial.last_input_required_step_id == "confirm_and_select" + + first_deploy = h.start_stream( + prompt=_cleanup_deployment_prompt(args.selection_prompt, h, "first"), + name="02-create-first-stack", + ) + first_stack_id = _wait_for_created_stack( + first_deploy, + exclude=set(), + timeout=args.event_timeout, + ) + h.checks["first rollback stack observed before rollback"] = bool(first_stack_id) + + rollback = h.start_stream(prompt=ROLLBACK_PROMPT, name="03-rollback-after-first-stack") + _wait_any( + [first_deploy, rollback], + _event_type("rollback_completed"), + description="rollback_completed after first stack", + timeout=args.event_timeout, + ) + _wait_any( + [first_deploy, rollback], + _input_required_step("confirm_and_select"), + description="post-rollback input_required(confirm_and_select)", + timeout=_post_rollback_timeout(args), + ) + cleanup_stack_ids = _cleanup_target_stack_ids(h, exclude=set()) + h.checks["rollback cleanup ledger includes first stack"] = bool(first_stack_id) and ( + first_stack_id in cleanup_stack_ids + ) + h.checks["rollback cleanup target stacks observed"] = bool(cleanup_stack_ids) + + second_deploy = h.start_stream( + prompt=_cleanup_deployment_prompt(args.selection_prompt, h, "second"), + name="04-select-second-stack", + ) + _wait_any( + [second_deploy], + _step_started("deploying"), + description="second deployment step_started(deploying)", + timeout=args.event_timeout, + ) + for stream in (first_deploy, rollback, second_deploy): + _join_stream_or_note(stream, h) + + _finish_pipeline_after_possible_input(h, second_deploy.summary, args) + h.checks["pipeline completed after second deployment"] = _completed_snapshot_or_stream(h, second_deploy.summary) + h.fetch_state("after-second-stack") + second_stack_id = _created_stack_id_from_stream(second_deploy, exclude=set(cleanup_stack_ids)) + h.checks["second stack created after rollback"] = bool(second_stack_id) + h.checks["second stack differs from first rollback stack"] = bool(second_stack_id) and ( + second_stack_id != first_stack_id + ) + cleanup_stack_ids = _cleanup_target_stack_ids( + h, + exclude={stack_id for stack_id in [second_stack_id] if stack_id}, + ) + h.checks["rollback cleanup ledger includes first stack"] = bool(first_stack_id) and ( + first_stack_id in cleanup_stack_ids + ) + h.checks["rollback cleanup target stacks observed"] = bool(cleanup_stack_ids) + + if kill_during_cleanup: + cleanup_stream = h.start_stream( + prompt=args.normal_followup_prompt, + name="05-cleanup-running", + task_id="", + ) + _wait_for_cleanup_started(h, cleanup_stream, first_stack_id, timeout=args.event_timeout) + h.kill9_and_restart() + _join_after_kill(cleanup_stream, h) + h.snapshots["after_cleanup_restart"] = h.fetch_state("after-cleanup-restart") + cleanup_summary = h.stream(prompt=CLEANUP_RECOVERY_PROMPT, name="06-cleanup-after-restart", task_id="") + h.checks["cleanup retriggered after restart"] = _events_file_has_cleanup_event( + h.run_dir / "06-cleanup-after-restart.events.jsonl", + stack_id=first_stack_id, + event_types={"cleanup_started", "cleanup_progress", "cleanup_completed"}, + ) + else: + cleanup_summary = h.stream( + prompt=args.normal_followup_prompt, + name="05-cleanup-normal-turn", + task_id="", + ) + h.checks["cleanup normal turn stayed in same context"] = cleanup_summary.context_id == h.context_id + h.checks["cleanup normal turn used normal task"] = cleanup_summary.task_id != h.pipeline_task_id + + after_cleanup = h.fetch_state("after-cleanup") + cleanup_resource = _cleanup_resource_for_stack(after_cleanup, first_stack_id) + h.checks["first rollback stack cleanup completed in snapshot"] = _cleanup_resource_completed(cleanup_resource) + h.checks["rollback cleanup stacks completed in snapshot"] = bool(cleanup_stack_ids) and all( + _cleanup_resource_completed(_cleanup_resource_for_stack(after_cleanup, stack_id)) + for stack_id in cleanup_stack_ids + ) + h.checks["cleanup snapshot does not target second stack"] = ( + bool(second_stack_id) and _cleanup_resource_for_stack(after_cleanup, second_stack_id) is None + ) + + ros_stack_ids = _unique_strings([*cleanup_stack_ids, second_stack_id]) + ros_states = _capture_ros_stack_states( + h, + ros_stack_ids, + "after-cleanup", + ) + h.checks["ROS first rollback stack deleted"] = _ros_stack_deleted(ros_states.get(first_stack_id, {})) + h.checks["ROS rollback cleanup stacks deleted"] = bool(cleanup_stack_ids) and all( + _ros_stack_deleted(ros_states.get(stack_id, {})) for stack_id in cleanup_stack_ids + ) + h.checks["ROS second stack retained"] = bool(second_stack_id) and _ros_stack_retained( + ros_states.get(second_stack_id, {}) + ) + + return _run_with_harness(args, scenario, callback) + + def _complete_pipeline(h: ScenarioHarness, args: argparse.Namespace) -> None: initial = h.stream(prompt=args.initial_prompt, name="01-initial", context_id="", task_id="") initial = _answer_intervening_ask_inputs(h, initial, name_prefix="01-initial") @@ -985,10 +1483,7 @@ def predicate(event: Any, _summary: StreamSummary) -> bool: return ( isinstance(envelope, dict) and envelope.get("eventType") == "input_required" - and ( - (isinstance(step, dict) and step.get("id") == step_id) - or data_step_id == step_id - ) + and ((isinstance(step, dict) and step.get("id") == step_id) or data_step_id == step_id) ) return predicate @@ -1023,14 +1518,18 @@ def _wait_any( ) -> EventMatch: deadline = time.monotonic() + timeout last_error = "" + active_streams = list(streams) while time.monotonic() < deadline: - for stream in streams: + for stream in list(active_streams): try: return stream.wait_for(predicate, description=description, timeout=0.25) except TimeoutError: continue except RuntimeError as exc: last_error = str(exc) + active_streams.remove(stream) + if not active_streams: + break time.sleep(0.05) raise TimeoutError(f"Timed out waiting for {description}; last_error={last_error}") @@ -1067,9 +1566,7 @@ def _wait_for_with_intervening_ask_inputs( answered_count += 1 if answered_count > 4: raise RuntimeError(f"too many intervening ask_user_question inputs before {description}") from exc - h.notes.append( - f"answered intervening ask_user_question while waiting for {description}: {stream.name}" - ) + h.notes.append(f"answered intervening ask_user_question while waiting for {description}: {stream.name}") answer = h.start_stream( prompt=INTERVENING_ASK_ANSWER, name=f"{name_prefix}-answer-ask-{answered_count}", @@ -1265,11 +1762,7 @@ def _jsonrpc_result(response: Any) -> Any: def _task_response_matches(response: Any, *, task_id: str, context_id: str) -> bool: result = _jsonrpc_result(response) identity = _a2a_task_identity(result) - return ( - isinstance(identity, dict) - and identity.get("taskId") == task_id - and identity.get("contextId") == context_id - ) + return isinstance(identity, dict) and identity.get("taskId") == task_id and identity.get("contextId") == context_id def _task_status_state(response: Any) -> str: @@ -1286,11 +1779,7 @@ def _task_list_contains(response: Any, *, task_id: str, context_id: str) -> bool return False for task in tasks: identity = _a2a_task_identity(task) - if ( - isinstance(identity, dict) - and identity.get("taskId") == task_id - and identity.get("contextId") == context_id - ): + if isinstance(identity, dict) and identity.get("taskId") == task_id and identity.get("contextId") == context_id: return True return False @@ -1420,6 +1909,560 @@ def _has_any_marker(text: str, markers: Iterable[str]) -> bool: return any(marker in text for marker in markers) +def _join_stream_or_note(stream: BackgroundStream, h: ScenarioHarness) -> None: + try: + stream.join(timeout=h.args.stream_timeout) + except Exception as exc: + h.notes.append(f"{stream.name} ended while joining: {type(exc).__name__}: {exc}") + + +def _post_rollback_timeout(args: argparse.Namespace) -> float: + event_timeout = float(getattr(args, "event_timeout", 0) or 0) + stream_timeout = float(getattr(args, "stream_timeout", 0) or 0) + return max(event_timeout, min(stream_timeout, 900.0)) + + +def _cleanup_deployment_prompt(base_prompt: str, h: ScenarioHarness, label: str) -> str: + stack_name = _cleanup_stack_name(h, label) + completion_instruction = ( + "本轮是回滚窗口验证:CreateStack 成功后不要调用 complete_step,不要结束 deploying step;" + "只简短说明新建的 stack_id,并等待用户下一条指令。" + if label == "first" + else "complete_step 前必须在本轮对话中看到一次新的 CreateStack 成功,部署总结的 stack_id 必须来自这次新建。" + ) + return ( + f"{base_prompt}\n\n" + "E2E 强制部署约束:\n" + f"- 本轮唯一成功条件是新建一个 ROS stack,StackName 必须精确等于 `{stack_name}`。\n" + "- 任何已有 stack(即使是 CREATE_COMPLETE)都必须视为失败结果,不能作为部署成功依据。\n" + f"- 调用 ros_stack 或 aliyun_api CreateStack 前,必须复核工具参数里的 StackName 精确等于 `{stack_name}`。\n" + f"- 如果模板、文件名、候选方案或默认值给出了其他 StackName,必须覆盖为 `{stack_name}` 后再调用 CreateStack。\n" + f"- 如果已经用其他 StackName 调用失败,不能 GetStack 或复用那个 stack,必须改用 `{stack_name}` " + "重新 CreateStack。\n" + "- 如果无法使用上述 StackName 新建 stack,就停下来说明失败,不要调用 complete_step。\n" + f"{completion_instruction}" + "创建 VSwitch 时请先检查目标 VPC 已有 VSwitch CIDR,选择未占用且属于 VPC CIDR 的网段;" + "如果 CIDR 冲突,请选择另一个未占用网段并继续使用上述指定 StackName。" + ) + + +def _cleanup_stack_name(h: ScenarioHarness, label: str) -> str: + suffix = Path(getattr(h, "run_dir", "")).name.rsplit("-", maxsplit=1)[-1] or "stack" + safe_label = "".join(ch if ch.isalnum() else "-" for ch in label.lower()).strip("-") or "stack" + return f"iac-e2e-{suffix[:12]}-{safe_label}"[:128] + + +def _wait_for_observed_cleanup_stack( + h: ScenarioHarness, + *, + exclude: set[str], + timeout: float, +) -> str: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + stack_id = _latest_observed_stack_id(h, exclude=exclude) + if stack_id: + return stack_id + time.sleep(1.0) + raise TimeoutError("Timed out waiting for rollback cleanup ledger to observe a ROS stack") + + +def _wait_for_created_stack( + stream: BackgroundStream, + *, + exclude: set[str], + timeout: float, +) -> str: + match = _wait_any( + [stream], + _created_stack_event(exclude), + description="successful CreateStack stack_current_changed", + timeout=timeout, + ) + envelope = _extract_pipeline_envelope(match.event) + data = envelope.get("data") if isinstance(envelope, dict) else None + stack_id = _string_from_mapping(data, "stackId", "stack_id", "StackId") + if not stack_id: + raise RuntimeError("successful CreateStack event did not include a stack id") + return stack_id + + +def _created_stack_id_from_stream(stream: Any, *, exclude: set[str]) -> str | None: + for event in getattr(stream, "events", []) or []: + envelope = _extract_pipeline_envelope(event) + if not isinstance(envelope, dict) or envelope.get("eventType") != "stack_current_changed": + continue + data = envelope.get("data") + if not isinstance(data, dict): + continue + if str(data.get("provider") or "").lower() != "ros": + continue + if data.get("action") != "CreateStack" or data.get("isSuccess") is not True: + continue + stack_id = _string_from_mapping(data, "stackId", "stack_id", "StackId") + if stack_id and stack_id not in exclude: + return stack_id + return None + + +def _created_stack_event(exclude: set[str]) -> Callable[[Any, StreamSummary], bool]: + def predicate(event: Any, _summary: StreamSummary) -> bool: + envelope = _extract_pipeline_envelope(event) + if not isinstance(envelope, dict) or envelope.get("eventType") != "stack_current_changed": + return False + data = envelope.get("data") + if not isinstance(data, dict): + return False + if str(data.get("provider") or "").lower() != "ros": + return False + if data.get("action") != "CreateStack" or data.get("isSuccess") is not True: + return False + stack_id = _string_from_mapping(data, "stackId", "stack_id", "StackId") + return bool(stack_id and stack_id not in exclude) + + return predicate + + +def _latest_observed_stack_id(h: ScenarioHarness, *, exclude: set[str]) -> str | None: + resources = _cleanup_ledger_items(h, "observed_resources") + for resource in reversed(resources): + if not _is_ros_stack_resource(resource): + continue + if str(resource.get("observed_action") or resource.get("action") or "") != "CreateStack": + continue + stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + if stack_id and stack_id not in exclude: + return stack_id + return None + + +def _cleanup_ledger_items(h: ScenarioHarness, key: str) -> list[dict[str, Any]]: + if not getattr(h, "context_id", ""): + return [] + try: + from iac_code.services.session_storage import SessionStorage + + cwd, session_id = _pipeline_session_identity(h) + session_dir = SessionStorage().session_dir(cwd, session_id) + paths = [session_dir / "pipeline" / "cleanup.yaml", session_dir / "a2a" / "pipeline" / "cleanup.yaml"] + data = None + for path in paths: + if path.exists(): + data = yaml.safe_load(path.read_text(encoding="utf-8")) + break + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return [] + if not isinstance(data, dict): + return [] + values = data.get(key) + return [item for item in values if isinstance(item, dict)] if isinstance(values, list) else [] + + +def _pipeline_session_identity(h: ScenarioHarness) -> tuple[str, str]: + context_id = str(getattr(h, "context_id", "") or "") + cwd = str(getattr(h, "cwd", "") or "") + run_dir_value = getattr(h, "run_dir", None) + if context_id and run_dir_value is not None: + path = Path(run_dir_value) / "a2a-persistence" / "contexts" / f"{context_id}.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + data = None + if isinstance(data, dict): + session_id = data.get("session_id") + persisted_cwd = data.get("cwd") + if isinstance(session_id, str) and session_id: + return (persisted_cwd if isinstance(persisted_cwd, str) and persisted_cwd else cwd, session_id) + return cwd, context_id + + +def _wait_for_cleanup_started( + h: ScenarioHarness, + stream: BackgroundStream, + stack_id: str, + *, + timeout: float, +) -> None: + try: + _wait_any( + [stream], + _cleanup_event_for_stack(stack_id, {"cleanup_started", "cleanup_progress"}), + description=f"cleanup_started({stack_id})", + timeout=timeout, + ) + return + except Exception as exc: + h.notes.append(f"did not observe cleanup_started event before fallback: {exc}") + _wait_for_cleanup_ledger_status(h, stack_id, {"started", "in_progress"}, timeout=timeout) + + +def _wait_for_cleanup_ledger_status( + h: ScenarioHarness, + stack_id: str, + statuses: set[str], + *, + timeout: float, +) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for resource in _cleanup_ledger_items(h, "cleanup_resources"): + if _string_from_mapping(resource, "resource_id", "resourceId") != stack_id: + continue + if str(resource.get("cleanup_status") or resource.get("cleanupStatus") or "") in statuses: + return + time.sleep(0.5) + raise TimeoutError(f"Timed out waiting for cleanup ledger status {sorted(statuses)} on {stack_id}") + + +def _cleanup_event_for_stack( + stack_id: str, + event_types: set[str], +) -> Callable[[Any, StreamSummary], bool]: + def predicate(event: Any, _summary: StreamSummary) -> bool: + envelope = _extract_pipeline_envelope(event) + if not isinstance(envelope, dict) or envelope.get("eventType") not in event_types: + return False + data = envelope.get("data") + return isinstance(data, dict) and data.get("resourceId") == stack_id + + return predicate + + +def _events_file_has_cleanup_event(path: Path, *, stack_id: str, event_types: set[str]) -> bool: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return False + for line in lines: + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + envelope = _extract_pipeline_envelope(value) + if not isinstance(envelope, dict) or envelope.get("eventType") not in event_types: + continue + data = envelope.get("data") + if isinstance(data, dict) and data.get("resourceId") == stack_id: + return True + return False + + +def _run_dir_has_cleanup_events(run_dir: Path) -> bool: + return any(_events_file_has_cleanup_activity(path) for path in sorted(run_dir.glob("*.events.jsonl"))) + + +def _events_file_has_cleanup_activity(path: Path) -> bool: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return False + for line in lines: + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + envelope = _extract_pipeline_envelope(value) + if isinstance(envelope, dict) and _pipeline_envelope_has_cleanup_activity(envelope): + return True + return False + + +def _pipeline_envelope_has_cleanup_activity(envelope: dict[str, Any]) -> bool: + if envelope.get("eventType") in CLEANUP_EVENT_TYPES or envelope.get("scope") == "cleanup": + return True + data = envelope.get("data") + cleanup = data.get("cleanup") if isinstance(data, dict) else None + return isinstance(cleanup, dict) and _cleanup_payload_has_targets(cleanup) + + +def _cleanup_resource_for_stack(response: Any, stack_id: str | None) -> dict[str, Any] | None: + if not stack_id: + return None + cleanup = _snapshot_cleanup(response) + resources = cleanup.get("resources") if isinstance(cleanup, dict) else None + if not isinstance(resources, list): + return None + for resource in resources: + if isinstance(resource, dict) and resource.get("resourceId") == stack_id: + return resource + return None + + +def _cleanup_target_stack_ids(h: ScenarioHarness, *, exclude: set[str]) -> list[str]: + stack_ids: list[str] = [] + for resource in _cleanup_ledger_items(h, "cleanup_resources"): + if not _is_ros_stack_resource(resource): + continue + if resource.get("cleanup_required") is False or resource.get("cleanupRequired") is False: + continue + stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + if stack_id and stack_id not in exclude: + stack_ids.append(stack_id) + return _unique_strings(stack_ids) + + +def _cleanup_resource_completed(resource: dict[str, Any] | None) -> bool: + if not isinstance(resource, dict): + return False + cleanup_status = resource.get("cleanupStatus") or resource.get("cleanup_status") or resource.get("status") + stack_status = resource.get("stackStatus") or resource.get("progressStatus") or resource.get("progress_status") + return cleanup_status == "completed" and stack_status == "DELETE_COMPLETE" + + +def _snapshot_cleanup(response: Any) -> dict[str, Any]: + snapshot = _snapshot(response) + cleanup = snapshot.get("cleanup") if isinstance(snapshot, dict) else None + return cleanup if isinstance(cleanup, dict) else {} + + +def _snapshot_has_cleanup_activity(response: Any) -> bool: + return _cleanup_payload_has_targets(_snapshot_cleanup(response)) + + +def _cleanup_payload_has_targets(cleanup: dict[str, Any]) -> bool: + resources = cleanup.get("resources") + if isinstance(resources, list) and any(isinstance(item, dict) for item in resources): + return True + history = cleanup.get("history") + if isinstance(history, list) and history: + return True + resource_count = cleanup.get("resourceCount", cleanup.get("resource_count")) + if _positive_int(resource_count): + return True + status = str(cleanup.get("status") or "") + return status in CLEANUP_ACTIVE_STATUSES + + +def _positive_int(value: Any) -> bool: + if isinstance(value, bool): + return False + if isinstance(value, int): + return value > 0 + if isinstance(value, str): + try: + return int(value) > 0 + except ValueError: + return False + return False + + +def _cleanup_ledger_has_required_resources(h: ScenarioHarness) -> bool: + for resource in _cleanup_ledger_items(h, "cleanup_resources"): + if resource.get("cleanup_required") is False or resource.get("cleanupRequired") is False: + continue + return True + return False + + +def _session_has_cleanup_prompt(h: ScenarioHarness) -> bool: + if not getattr(h, "context_id", ""): + return False + try: + from iac_code.services.session_storage import SessionStorage + + cwd, session_id = _pipeline_session_identity(h) + return _session_file_has_cleanup_prompt(SessionStorage().session_path(cwd, session_id)) + except OSError: + return False + + +def _session_file_has_cleanup_prompt(path: Path) -> bool: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return False + for line in lines: + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + metadata = value.get("metadata") if isinstance(value, dict) else None + if isinstance(metadata, dict) and metadata.get("type") == CLEANUP_PROMPT_METADATA_TYPE: + return True + return False + + +def _snapshot_current_stack_id(response: Any, *, exclude: set[str]) -> str | None: + snapshot = _snapshot(response) + stacks = snapshot.get("stacks") if isinstance(snapshot, dict) else None + if not isinstance(stacks, dict): + return None + current = stacks.get("current") + current_id = _active_stack_id_from_record(current) + if current_id and current_id not in exclude: + return current_id + by_id = stacks.get("byId") + if isinstance(by_id, dict): + for record in reversed(list(by_id.values())): + stack_id = _active_stack_id_from_record(record) + if stack_id and stack_id not in exclude: + return stack_id + history = stacks.get("history") + if isinstance(history, list): + for record in reversed(history): + stack_id = _active_stack_id_from_record(record) + if stack_id and stack_id not in exclude: + return stack_id + return None + + +def _active_stack_id_from_record(record: Any) -> str | None: + if not isinstance(record, dict): + return None + if record.get("current") is False or record.get("cleared") is True: + return None + if record.get("isSuccess") is False: + return None + status = str(record.get("stackStatus") or record.get("status") or "") + if status.endswith("_FAILED"): + return None + action = record.get("action") + if action == "DeleteStack": + return None + return _string_from_mapping(record, "stackId", "stack_id", "StackId", "id") + + +def _capture_ros_stack_states(h: ScenarioHarness, stack_ids: Iterable[str], name: str) -> dict[str, dict[str, Any]]: + states: dict[str, dict[str, Any]] = {} + for stack_id in stack_ids: + region_id = _region_for_stack(h, stack_id) + states[stack_id] = _get_ros_stack_state(stack_id=stack_id, region_id=region_id, redaction_env=h.server_env) + redacted = _redact_json_value(states, h.server_env) + _write_json(h.run_dir / f"{name}.ros-stack-states.json", redacted) + h.snapshots[f"{name}.ros-stack-states"] = redacted + return states + + +def _get_ros_stack_state( + *, + stack_id: str, + region_id: str, + redaction_env: dict[str, str] | None, +) -> dict[str, Any]: + try: + from alibabacloud_ros20190910 import models as ros_models + + from iac_code.services.cloud_credentials import CloudCredentials + from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory + + credential = CloudCredentials().get_provider("aliyun") + effective_region = region_id or (credential.region_id if credential is not None else "") + client = RosClientFactory.create(credential, effective_region) + request = ros_models.GetStackRequest(stack_id=stack_id, region_id=effective_region) + response = client.get_stack(request) + body = response.body.to_map() + return { + "stack_id": str(body.get("StackId") or stack_id), + "stack_name": str(body.get("StackName") or ""), + "region_id": effective_region, + "status": str(body.get("Status") or ""), + "status_reason": str(body.get("StatusReason") or ""), + "not_found": False, + } + except Exception as exc: + message = _redact_sensitive_text(str(exc), redaction_env) + return { + "stack_id": stack_id, + "region_id": region_id, + "status": "", + "not_found": _is_ros_stack_not_found(exc), + "error": _compact_text(message, max_chars=1000), + } + + +def _is_ros_stack_not_found(exc: BaseException) -> bool: + code = str(getattr(exc, "code", "") or "") + message = str(exc) + combined = f"{code} {message}".lower() + not_found_tokens = ( + "stacknotfound", + "notfound.stack", + "entitynotexist.stack", + "specified stack does not exist", + "stack could not be found", + "stack not found", + ) + return any(token in combined for token in not_found_tokens) + + +def _region_for_stack(h: ScenarioHarness, stack_id: str) -> str: + for snapshot in reversed(list(h.snapshots.values())): + region = _region_for_stack_in_snapshot(snapshot, stack_id) + if region: + return region + for key in ("cleanup_resources", "observed_resources"): + for resource in reversed(_cleanup_ledger_items(h, key)): + if _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") == stack_id: + region = _string_from_mapping(resource, "region_id", "regionId", "RegionId") + if region: + return region + return h.server_env.get("ALIBABA_CLOUD_REGION_ID", "") + + +def _region_for_stack_in_snapshot(response: Any, stack_id: str) -> str: + cleanup_resource = _cleanup_resource_for_stack(response, stack_id) + if cleanup_resource is not None: + region = _string_from_mapping(cleanup_resource, "regionId", "region_id", "RegionId") + if region: + return region + snapshot = _snapshot(response) + stacks = snapshot.get("stacks") if isinstance(snapshot, dict) else None + if not isinstance(stacks, dict): + return "" + by_id = stacks.get("byId") + if isinstance(by_id, dict): + record = by_id.get(stack_id) + region = _string_from_mapping(record, "regionId", "region_id", "RegionId") if isinstance(record, dict) else None + if region: + return region + current = stacks.get("current") + if isinstance(current, dict) and _string_from_mapping(current, "stackId", "stack_id", "StackId") == stack_id: + return _string_from_mapping(current, "regionId", "region_id", "RegionId") or "" + return "" + + +def _ros_stack_deleted(state: dict[str, Any]) -> bool: + if not isinstance(state, dict): + return False + if state.get("not_found") is True: + return True + return state.get("status") in ROS_STACK_DELETED_STATUSES + + +def _ros_stack_retained(state: dict[str, Any]) -> bool: + if not isinstance(state, dict) or state.get("not_found") is True: + return False + status = state.get("status") + return isinstance(status, str) and bool(status) and not status.startswith("DELETE_") + + +def _is_ros_stack_resource(resource: dict[str, Any]) -> bool: + provider = str(resource.get("provider") or "").lower() + resource_type = str(resource.get("resource_type") or resource.get("resourceType") or "").lower() + return provider == "ros" and resource_type == "stack" + + +def _unique_strings(values: Iterable[str | None]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str) or not value or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _string_from_mapping(mapping: Any, *keys: str) -> str | None: + if not isinstance(mapping, dict): + return None + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + + def _scenario_run_dir(args: argparse.Namespace, scenario: str) -> Path: if args.run_dir: return Path(args.run_dir).expanduser() @@ -1477,20 +2520,34 @@ def _validate_scenario_execution(args: argparse.Namespace, scenario: str) -> Non } _REAL_CLOUD_SCENARIOS = { "fault-after-snapshot", + "image-ask-waiting", + "image-initial", + "image-interrupt", + "image-normal-handoff", + "image-selection-waiting", "scenario1", "normal-running", "ask-waiting", "selection-waiting", + "rollback-step5-cleanup", + "rollback-step5-cleanup-recovery", *_RUNNING_STEP_SCENARIOS, *_ROLLBACK_SCENARIOS, *_CANCEL_SCENARIOS, } _SCENARIOS: dict[str, Callable[[argparse.Namespace, str], int]] = { + "image-ask-waiting": run_image_ask_waiting, + "image-initial": run_image_initial, + "image-interrupt": run_image_interrupt, + "image-normal-handoff": run_image_normal_handoff, + "image-selection-waiting": run_image_selection_waiting, "scenario1": run_scenario1, "normal-running": run_normal_running, "ask-waiting": run_ask_waiting, "selection-waiting": run_selection_waiting, "fault-after-snapshot": run_fault_after_snapshot, + "rollback-step5-cleanup": run_rollback_step5_cleanup, + "rollback-step5-cleanup-recovery": run_rollback_step5_cleanup_recovery, **{name: run_running_step for name in _RUNNING_STEP_SCENARIOS}, **{name: run_rollback for name in _ROLLBACK_SCENARIOS}, **{name: run_cancel for name in _CANCEL_SCENARIOS}, diff --git a/scripts/a2a/selling_console.py b/scripts/a2a/selling_console.py new file mode 100644 index 00000000..80805a26 --- /dev/null +++ b/scripts/a2a/selling_console.py @@ -0,0 +1,294 @@ +"""Local web console for A2A selling pipelines. + +The bundled web UI currently sends text input only; use the A2A debugger for +image-part request coverage. +""" + +from __future__ import annotations + +import argparse +import hashlib +import html +import importlib +import json +import os +import sys +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +a2a_debugger = importlib.import_module("scripts.a2a.debugger") + +WEB_ROOT = Path(__file__).resolve().with_name("selling_console_web") +TEMPLATE_PLACEHOLDERS = ( + "__DEFAULTS_JSON__", + "__DEFAULT_SERVER_URL_ATTR__", + "__DEFAULT_CWD_ATTR__", + "__STATIC_ASSET_VERSION__", +) + + +@dataclass(frozen=True) +class StaticAsset: + path: Path + content_type: str + + +STYLE_ASSET = StaticAsset(WEB_ROOT / "styles.css", "text/css; charset=utf-8") +APP_ASSET = StaticAsset(WEB_ROOT / "app.js", "application/javascript; charset=utf-8") +STATIC_ASSETS = (STYLE_ASSET, APP_ASSET) + + +@dataclass(frozen=True) +class SellingConsoleConfig: + host: str + port: int + default_server_url: str + default_cwd: str + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a local A2A selling pipeline console.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=41980) + parser.add_argument("--default-server-url", default="http://127.0.0.1:41299") + parser.add_argument("--default-cwd", default=os.getcwd()) + return parser.parse_args(argv) + + +def _html_attribute_value(value: str) -> str: + escaped = html.escape(value, quote=True) + for placeholder in TEMPLATE_PLACEHOLDERS: + escaped = escaped.replace(placeholder, placeholder.replace("_", "_")) + return escaped + + +def _json_for_template(value: object) -> str: + json_value = a2a_debugger._json_for_script(value) + for placeholder in TEMPLATE_PLACEHOLDERS: + json_value = json_value.replace(placeholder, placeholder.replace("_", "\\u005f")) + return json_value + + +def _static_asset_version() -> str: + digest = hashlib.sha256() + for asset in STATIC_ASSETS: + digest.update(asset.path.name.encode("utf-8")) + digest.update(asset.path.read_bytes()) + return digest.hexdigest()[:12] + + +def render_index_html(config: SellingConsoleConfig) -> str: + defaults_json = _json_for_template( + { + "serverUrl": config.default_server_url, + "cwd": config.default_cwd, + } + ) + return ( + (WEB_ROOT / "index.html") + .read_text(encoding="utf-8") + .replace("__DEFAULT_SERVER_URL_ATTR__", _html_attribute_value(config.default_server_url)) + .replace("__DEFAULT_CWD_ATTR__", _html_attribute_value(config.default_cwd)) + .replace("__DEFAULTS_JSON__", defaults_json) + .replace("__STATIC_ASSET_VERSION__", _static_asset_version()) + ) + + +def _send_text(handler: BaseHTTPRequestHandler, status: int, body: str, content_type: str) -> None: + raw_body = body.encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(raw_body))) + handler.end_headers() + handler.wfile.write(raw_body) + + +def _static_asset_for_request(path: str) -> StaticAsset | None: + if path == "/styles.css": + return STYLE_ASSET + if path == "/app.js": + return APP_ASSET + return None + + +def _send_static(handler: BaseHTTPRequestHandler, path: str) -> bool: + asset = _static_asset_for_request(path) + if asset is None: + return False + web_root = WEB_ROOT.resolve() + candidate = asset.path.resolve() + try: + candidate.relative_to(web_root) + except ValueError: + return False + if not candidate.is_file(): + return False + _send_text(handler, 200, candidate.read_text(encoding="utf-8"), asset.content_type) + return True + + +def _proxy_error_body(exc: BaseException) -> dict[str, object]: + if isinstance(exc, HTTPError): + raw = exc.read() + data, text = a2a_debugger._decode_json_text(raw) + return a2a_debugger._proxy_error( + a2a_debugger.ProxyResult( + status_code=exc.code, + data=data, + text=text, + headers=dict(exc.headers.items()), + error=f"HTTP {exc.code}", + ) + ) + return a2a_debugger._proxy_error( + a2a_debugger.ProxyResult(status_code=0, data=None, text="", headers={}, error=str(exc)) + ) + + +def _write_sse_error_event(handler: BaseHTTPRequestHandler, message: str) -> None: + body = f"data: {json.dumps({'ok': False, 'error': message}, ensure_ascii=False)}\n\n".encode("utf-8") + try: + handler.wfile.write(body) + handler.wfile.flush() + except OSError: + return + + +def create_server(config: SellingConsoleConfig) -> ThreadingHTTPServer: + class SellingConsoleHTTPServer(ThreadingHTTPServer): + allow_reuse_address = sys.platform != "win32" + + class SellingConsoleHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + return None + + def do_GET(self) -> None: + parsed = urlparse(self.path) + try: + if parsed.path == "/": + _send_text(self, 200, render_index_html(config), "text/html; charset=utf-8") + return + if parsed.path == "/api/health": + status, body = a2a_debugger._health_response( + a2a_debugger._query_params(self.path).get("serverUrl", "") + ) + a2a_debugger._send_json(self, status, body) + return + if parsed.path == "/api/pipeline/state": + status, body = a2a_debugger._pipeline_state_response(a2a_debugger._query_params(self.path)) + a2a_debugger._send_json(self, status, body) + return + if parsed.path == "/api/task/get": + status, body = a2a_debugger._task_get_response(a2a_debugger._query_params(self.path)) + a2a_debugger._send_json(self, status, body) + return + if _send_static(self, parsed.path): + return + except ValueError as exc: + a2a_debugger._send_json(self, 400, {"ok": False, "error": str(exc)}) + return + except (HTTPError, URLError, TimeoutError, OSError) as exc: + a2a_debugger._send_json(self, 502, _proxy_error_body(exc)) + return + a2a_debugger._send_json(self, 404, {"ok": False, "error": "Not found"}) + + def do_POST(self) -> None: + parsed = urlparse(self.path) + try: + if parsed.path == "/api/message/stream": + body = a2a_debugger._read_json_body(self) + server_url, payload = a2a_debugger._message_stream_body(body) + try: + with a2a_debugger._open_sse_stream(server_url, payload) as response: + headers = getattr(response, "headers", {}) + content_type = "" + if hasattr(headers, "get"): + content_type = str(headers.get("Content-Type", "")).lower() + if content_type and "text/event-stream" not in content_type: + raw = response.read() + data, _text = a2a_debugger._decode_json_text(raw) + message = a2a_debugger._jsonrpc_error_message(data) + if message: + a2a_debugger._send_sse_event( + self, + 200, + { + "type": "error", + "error": message, + "statusCode": response.status, + "body": data, + }, + ) + return + a2a_debugger._send_sse_error(self, 502, "Target server returned a non-SSE response") + return + self.send_response(response.status) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.end_headers() + response_iter = iter(response) + while True: + try: + line = next(response_iter) + except StopIteration: + break + except (TimeoutError, URLError, OSError) as exc: + _write_sse_error_event(self, str(exc)) + return + try: + self.wfile.write(line) + self.wfile.flush() + except OSError as exc: + if a2a_debugger._is_client_disconnect_error(exc): + return + return + except HTTPError as exc: + a2a_debugger._send_sse_error(self, 502, f"HTTP {exc.code}") + except (TimeoutError, URLError, OSError) as exc: + a2a_debugger._send_sse_error(self, 502, str(exc)) + return + if parsed.path == "/api/task/cancel": + body = a2a_debugger._read_json_body(self) + status, response_body = a2a_debugger._task_cancel_response(body) + a2a_debugger._send_json(self, status, response_body) + return + except ValueError as exc: + a2a_debugger._send_json(self, 400, {"ok": False, "error": str(exc)}) + return + except (HTTPError, URLError, TimeoutError, OSError) as exc: + a2a_debugger._send_json(self, 502, _proxy_error_body(exc)) + return + a2a_debugger._send_json(self, 404, {"ok": False, "error": "Not found"}) + + return SellingConsoleHTTPServer((config.host, config.port), SellingConsoleHandler) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + config = SellingConsoleConfig( + host=args.host, + port=args.port, + default_server_url=args.default_server_url, + default_cwd=args.default_cwd, + ) + server = create_server(config) + host, port = server.server_address + print(f"Selling pipeline console listening on http://{host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + return 0 + finally: + server.shutdown() + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/a2a/selling_console_web/README.md b/scripts/a2a/selling_console_web/README.md new file mode 100644 index 00000000..029d2781 --- /dev/null +++ b/scripts/a2a/selling_console_web/README.md @@ -0,0 +1,34 @@ +# Selling Console Web + +Standalone static frontend for `scripts/a2a/selling_console.py`. It is used to drive the selling pipeline, inspect step progress, select candidate plans, and continue into normal chat after deployment. + +The web console currently sends text input only. Use `scripts/a2a/debugger.py` for A2A image-part coverage. + +## Run + +From the repository root, start the A2A server first: + +```bash +PATH="$HOME/.local/bin:$PATH" IAC_CODE_MODE=pipeline \ +uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 +``` + +Then start the web console: + +```bash +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/a2a/selling_console.py --port 41980 \ + --default-server-url http://127.0.0.1:41299 \ + --default-cwd "$PWD" +``` + +Then open `http://127.0.0.1:41980`. + +## Files + +- `index.html` renders the page shell. +- `styles.css` contains layout, chat, plan cards, and progress visuals. +- `app.js` handles A2A stream parsing, UI state, debug controls, and interactions. +- `design/` keeps standalone visual explorations for progress variants. + +The debug panel is collapsed by default. Expand it only when checking connection settings, progress variant parameters, context IDs, or recent stream diagnostics. diff --git a/scripts/a2a/selling_console_web/app.js b/scripts/a2a/selling_console_web/app.js new file mode 100644 index 00000000..01105ded --- /dev/null +++ b/scripts/a2a/selling_console_web/app.js @@ -0,0 +1,4327 @@ +(function () { + const STEP_ORDER = ["intent_parsing", "architecture_planning", "evaluate_candidates", "confirm_and_select", "deploying"]; + const STEP_LABELS = { + intent_parsing: "需求理解", + architecture_planning: "架构规划", + evaluate_candidates: "方案评估", + confirm_and_select: "方案选择", + deploying: "确认部署", + }; + const PROGRESS_VARIANT_ORDER = ["a", "b", "d"]; + const PROGRESS_VARIANT_LABELS = { + a: "A 箭头轨道", + b: "B 脉冲线路", + d: "D 输入框融合", + }; + const DEFAULT_PROGRESS_UI = { + variant: "b", + activeStepIndex: null, + a: { + sweepMs: 1800, + }, + b: { + xPercent: 28, + yPercent: 49, + t1: 140, + t2: 540, + maxAmplitude: 9, + pauseTime: 510, + }, + d: { + t1: 1800, + t2: 300, + }, + }; + const PROGRESS_PARAM_DEFS = { + a: [ + { key: "sweepMs", label: "扫光周期", min: 800, max: 2800, step: 100, unit: "ms" }, + ], + b: [ + { key: "xPercent", label: "X", min: 6, max: 38, step: 1, unit: "%" }, + { key: "yPercent", label: "Y", min: 20, max: 90, step: 1, unit: "%" }, + { key: "t1", label: "T1", min: 80, max: 700, step: 20, unit: "ms" }, + { key: "t2", label: "T2", min: 160, max: 1400, step: 20, unit: "ms" }, + { key: "maxAmplitude", label: "最大振幅", min: 8, max: 22, step: 1, unit: "" }, + { key: "pauseTime", label: "停顿时间", min: 120, max: 1200, step: 30, unit: "ms" }, + ], + d: [ + { key: "t1", label: "T1", min: 800, max: 3200, step: 100, unit: "ms" }, + { key: "t2", label: "T2", min: 0, max: 1200, step: 50, unit: "ms" }, + ], + }; + const MAX_CANDIDATE_SUB_EVENTS = 96; + const CURRENT_STEP_EVENT_TYPES = new Set([ + "permission_requested", + "text_delta", + "tool_call", + "tool_result", + "tool_started", + "tool_use", + ]); + const NORMAL_HANDOFF_TEXT = "部署流程已完成,已进入普通会话。可以继续追问资源、运维或变更需求。"; + const CANDIDATE_SUBSTEP_LABELS = { + template_generating: "模板生成", + template_generation: "模板生成", + template_validating: "模板校验", + template_validation: "模板校验", + cost_estimating: "成本估算", + cost_estimation: "成本估算", + cost_estimate: "成本估算", + price_estimating: "价格估算", + quality_review: "质量复核", + architecture_review: "架构复核", + risk_review: "风险复核", + resource_planning: "资源规划", + requirement_matching: "需求匹配", + }; + const CANDIDATE_STEP_IDS = new Set([ + "candidate", + "candidate_generation", + "candidate_selection", + "candidate_summary", + "cost_estimation", + "evaluate_candidate", + "evaluate_candidates", + "resource_evaluation", + ]); + + function createSteps() { + return STEP_ORDER.reduce((steps, stepId) => { + steps[stepId] = { + id: stepId, + label: STEP_LABELS[stepId], + status: "pending", + events: [], + }; + return steps; + }, {}); + } + + function mergeProgressParams(variant, params) { + const defaults = DEFAULT_PROGRESS_UI[variant] || {}; + const source = params && typeof params === "object" ? params : {}; + return Object.keys(defaults).reduce((result, key) => { + const numericValue = Number(source[key]); + result[key] = Number.isFinite(numericValue) ? numericValue : defaults[key]; + return result; + }, {}); + } + + function mergeProgressUi(value) { + const source = value && typeof value === "object" ? value : {}; + const variant = PROGRESS_VARIANT_ORDER.includes(source.variant) ? source.variant : DEFAULT_PROGRESS_UI.variant; + const rawActiveStepIndex = + source.activeStepIndex === null || source.activeStepIndex === undefined ? null : Number(source.activeStepIndex); + return { + variant, + activeStepIndex: + Number.isInteger(rawActiveStepIndex) && rawActiveStepIndex >= 0 && rawActiveStepIndex < STEP_ORDER.length + ? rawActiveStepIndex + : null, + a: mergeProgressParams("a", source.a), + b: mergeProgressParams("b", source.b), + d: mergeProgressParams("d", source.d), + }; + } + + function createInitialState(defaults = {}) { + const stateDefaults = clonePlainData(defaults && typeof defaults === "object" ? defaults : {}); + return { + defaults: stateDefaults, + serverUrl: stateDefaults.serverUrl || "", + cwd: stateDefaults.cwd || "", + contextId: "", + pipelineTaskId: "", + activeTaskId: "", + currentStepId: "", + lastSequence: 0, + status: "idle", + pipelineStarted: Boolean(stateDefaults.pipelineStarted), + normalHandoffReady: false, + steps: createSteps(), + candidates: [], + selectedCandidateIndex: null, + selectedPendingInputOptionId: stateDefaults.selectedPendingInputOptionId || "", + pendingInput: null, + permission: null, + userMessages: Array.isArray(stateDefaults.userMessages) ? clonePlainData(stateDefaults.userMessages) : [], + normalTurns: Array.isArray(stateDefaults.normalTurns) ? clonePlainData(stateDefaults.normalTurns) : [], + pendingNormalUserMessageId: stateDefaults.pendingNormalUserMessageId || "", + expandedStepDetails: clonePlainData(stateDefaults.expandedStepDetails || {}), + expandedCandidateSubpipelines: clonePlainData(stateDefaults.expandedCandidateSubpipelines || {}), + expandedNormalProcesses: clonePlainData(stateDefaults.expandedNormalProcesses || {}), + progressUi: mergeProgressUi(stateDefaults.progressUi), + diagnostics: { requests: [], sse: [], snapshots: [] }, + }; + } + + function cloneStep(step) { + return { + ...step, + events: Array.isArray(step.events) ? step.events.map(clonePlainData) : [], + }; + } + + function cloneCandidate(candidate) { + return clonePlainData({ + ...candidate, + costItems: Array.isArray(candidate.costItems) ? candidate.costItems : [], + subEvents: Array.isArray(candidate.subEvents) ? candidate.subEvents : [], + }); + } + + function clonePendingInput(pendingInput) { + if (!pendingInput) { + return null; + } + const nextPendingInput = clonePlainData(pendingInput); + return { + ...nextPendingInput, + prompt: nextPendingInput.prompt || nextPendingInput.question || "", + options: Array.isArray(nextPendingInput.options) ? nextPendingInput.options : [], + }; + } + + function cloneDiagnostics(diagnostics) { + const source = diagnostics || {}; + return clonePlainData({ + requests: Array.isArray(source.requests) ? [...source.requests] : [], + sse: Array.isArray(source.sse) ? [...source.sse] : [], + snapshots: Array.isArray(source.snapshots) ? [...source.snapshots] : [], + }); + } + + function clonePlainData(value) { + if (Array.isArray(value)) { + return value.map(clonePlainData); + } + if (value && typeof value === "object") { + return Object.keys(value).reduce((result, key) => { + result[key] = clonePlainData(value[key]); + return result; + }, {}); + } + return value; + } + + function cloneState(state) { + if (!state) { + return createInitialState(); + } + const steps = {}; + const defaultSteps = createSteps(); + STEP_ORDER.forEach((stepId) => { + steps[stepId] = cloneStep(state.steps && state.steps[stepId] ? state.steps[stepId] : defaultSteps[stepId]); + }); + return { + ...state, + defaults: clonePlainData(state.defaults || {}), + steps, + candidates: Array.isArray(state.candidates) ? state.candidates.map(cloneCandidate) : [], + selectedPendingInputOptionId: state.selectedPendingInputOptionId || "", + pendingInput: clonePendingInput(state.pendingInput), + permission: clonePlainData(state.permission), + currentStepId: state.currentStepId || "", + userMessages: Array.isArray(state.userMessages) ? state.userMessages.map(clonePlainData) : [], + normalTurns: Array.isArray(state.normalTurns) ? state.normalTurns.map(clonePlainData) : [], + pendingNormalUserMessageId: state.pendingNormalUserMessageId || "", + expandedStepDetails: clonePlainData(state.expandedStepDetails || {}), + expandedCandidateSubpipelines: clonePlainData(state.expandedCandidateSubpipelines || {}), + expandedNormalProcesses: clonePlainData(state.expandedNormalProcesses || {}), + pipelineStarted: Boolean(state.pipelineStarted), + progressUi: mergeProgressUi(state.progressUi), + diagnostics: cloneDiagnostics(state.diagnostics), + }; + } + + function pipelineFromMetadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return null; + } + if (metadata.pipeline) { + return metadata.pipeline; + } + const iacCode = metadata.iac_code || metadata.iacCode || metadata["iac-code"]; + if (iacCode && typeof iacCode === "object") { + return iacCode.pipeline || iacCode.pipelineEvent || iacCode.pipelineSnapshot || null; + } + return null; + } + + function valueOf(source, ...keys) { + if (!source || typeof source !== "object") { + return undefined; + } + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + return source[key]; + } + } + return undefined; + } + + function eventTypeOf(source) { + return valueOf(source, "eventType", "event_type"); + } + + function taskIdOf(source) { + return valueOf(source, "deliveryTaskId", "delivery_task_id", "taskId", "task_id"); + } + + function contextIdOf(source) { + return valueOf(source, "deliveryContextId", "delivery_context_id", "contextId", "context_id"); + } + + function sequenceOf(source) { + const sequence = valueOf(source, "sequence", "lastSequence", "last_sequence"); + const numericSequence = Number(sequence); + return Number.isFinite(numericSequence) ? numericSequence : null; + } + + function pendingInputOf(source) { + return valueOf(source, "pendingInput", "pending_input", "input"); + } + + function normalHandoffOf(source) { + return valueOf(source, "normalHandoff", "normal_handoff"); + } + + function targetModeOf(source) { + return valueOf(source, "targetMode", "target_mode"); + } + + function updateLastSequence(state, sequence) { + if (typeof sequence === "number") { + state.lastSequence = Math.max(state.lastSequence || 0, sequence); + } + } + + function extractPipelineEnvelope(payload) { + if (!payload || typeof payload !== "object") { + return null; + } + if (Array.isArray(payload)) { + for (const item of payload) { + const envelope = extractPipelineEnvelope(item); + if (envelope) { + return envelope; + } + } + return null; + } + + const metadataPipeline = pipelineFromMetadata(payload.metadata); + if (metadataPipeline) { + return metadataPipeline; + } + if (payload.iac_code && payload.iac_code.pipeline) { + return payload.iac_code.pipeline; + } + if (payload.iacCode && payload.iacCode.pipeline) { + return payload.iacCode.pipeline; + } + if (payload["iac-code"] && payload["iac-code"].pipeline) { + return payload["iac-code"].pipeline; + } + if (payload.pipeline || payload.pipelineEvent || payload.pipelineSnapshot) { + return payload.pipeline || payload.pipelineEvent || payload.pipelineSnapshot; + } + if (eventTypeOf(payload) || taskIdOf(payload) || contextIdOf(payload) || payload.step) { + return payload; + } + + const wrapperKeys = [ + "result", + "params", + "task", + "statusUpdate", + "status_update", + "status", + "message", + "event", + "events", + "snapshot", + ]; + for (const key of wrapperKeys) { + if (payload[key] && typeof payload[key] === "object") { + const envelope = extractPipelineEnvelope(payload[key]); + if (envelope) { + return envelope; + } + } + } + return null; + } + + function normalizeStatus(status) { + if (status === "input_required") { + return "waiting_input"; + } + return status || ""; + } + + function statusFromEventType(eventType, fallbackStatus) { + const statuses = { + step_started: "working", + step_completed: "completed", + step_failed: "failed", + input_required: "waiting_input", + }; + return statuses[eventType] || normalizeStatus(fallbackStatus); + } + + function normalizeStepId(step) { + const rawStepId = typeof step === "string" ? step : step && (step.id || step.name || step.stepId); + if (!rawStepId) { + return ""; + } + const stepId = String(rawStepId); + if (CANDIDATE_STEP_IDS.has(stepId) || stepId.startsWith("candidate_") || stepId.includes("candidate")) { + return "evaluate_candidates"; + } + if (STEP_ORDER.includes(stepId)) { + return stepId; + } + return stepId; + } + + function normalizeCandidateIndexValue(candidateIndex) { + if (candidateIndex === null || candidateIndex === undefined || candidateIndex === "") { + return candidateIndex; + } + const numericIndex = Number(candidateIndex); + return Number.isFinite(numericIndex) ? numericIndex : candidateIndex; + } + + function candidateFromDisplayItem(item) { + if (!item || typeof item !== "object") { + return null; + } + const detail = item.detail && typeof item.detail === "object" ? item.detail : item; + const nestedCandidate = + item.candidate && typeof item.candidate === "object" + ? item.candidate + : detail.candidate && typeof detail.candidate === "object" + ? detail.candidate + : {}; + const cost = + item.cost && typeof item.cost === "object" + ? item.cost + : detail.cost && typeof detail.cost === "object" + ? detail.cost + : {}; + const conclusions = + item.conclusions && typeof item.conclusions === "object" + ? item.conclusions + : detail.conclusions && typeof detail.conclusions === "object" + ? detail.conclusions + : {}; + const templateConclusion = conclusions.template && typeof conclusions.template === "object" ? conclusions.template : {}; + const costConclusion = conclusions.cost && typeof conclusions.cost === "object" ? conclusions.cost : {}; + const primitiveCost = (value) => (value && typeof value === "object" ? undefined : value); + const candidateIndex = + item.candidateIndex ?? + item.candidate_index ?? + item.optionIndex ?? + item.option_index ?? + item.index ?? + item.id ?? + (item.candidate && item.candidate.index) ?? + detail.candidateIndex ?? + detail.candidate_index ?? + detail.optionIndex ?? + detail.option_index ?? + detail.index ?? + detail.id ?? + null; + return { + name: + item.name || + item.candidateName || + item.candidate_name || + detail.candidateName || + detail.candidate_name || + nestedCandidate.candidateName || + nestedCandidate.candidate_name || + detail.name || + nestedCandidate.name || + item.title || + detail.title || + nestedCandidate.title || + item.label || + detail.label || + nestedCandidate.label || + item.template || + detail.template || + "", + candidateIndex: normalizeCandidateIndexValue(candidateIndex), + summary: + item.summary || + item.firstVersionDescription || + item.first_version_description || + item.planDescription || + item.plan_description || + item.pros || + item.topology || + detail.summary || + detail.firstVersionDescription || + detail.first_version_description || + detail.planDescription || + detail.plan_description || + detail.pros || + detail.topology || + templateConclusion.summary || + templateConclusion.description || + nestedCandidate.summary || + nestedCandidate.firstVersionDescription || + nestedCandidate.first_version_description || + item.description || + detail.description || + nestedCandidate.description || + nestedCandidate.topology || + nestedCandidate.pros || + "", + template: item.template || detail.template || "", + totalMonthlyCost: + item.totalMonthlyCost ?? + item.total_monthly_cost ?? + item.monthlyCost ?? + item.monthly_cost ?? + item.monthlyEstimate ?? + item.monthly_estimate ?? + item.roughMonthlyEstimate ?? + item.rough_monthly_estimate ?? + item.estimatedMonthlyCost ?? + item.estimated_monthly_cost ?? + primitiveCost(item.cost) ?? + item.price ?? + detail.totalMonthlyCost ?? + detail.total_monthly_cost ?? + detail.monthlyCost ?? + detail.monthly_cost ?? + detail.monthlyEstimate ?? + detail.monthly_estimate ?? + detail.roughMonthlyEstimate ?? + detail.rough_monthly_estimate ?? + detail.estimatedMonthlyCost ?? + detail.estimated_monthly_cost ?? + primitiveCost(detail.cost) ?? + detail.price ?? + cost.totalMonthlyCost ?? + cost.total_monthly_cost ?? + cost.monthlyCost ?? + cost.monthly_cost ?? + cost.monthlyEstimate ?? + cost.monthly_estimate ?? + costConclusion.totalMonthlyCost ?? + costConclusion.total_monthly_cost ?? + costConclusion.monthlyEstimate ?? + costConclusion.monthly_estimate ?? + nestedCandidate.totalMonthlyCost ?? + nestedCandidate.total_monthly_cost ?? + nestedCandidate.monthlyEstimate ?? + nestedCandidate.monthly_estimate ?? + "", + outputPath: + item.outputPath || + item.output_path || + detail.outputPath || + detail.output_path || + templateConclusion.outputPath || + templateConclusion.output_path || + templateConclusion.filePath || + templateConclusion.file_path || + nestedCandidate.outputPath || + nestedCandidate.output_path || + "", + costItems: Array.isArray(item.costItems) + ? clonePlainData(item.costItems) + : Array.isArray(detail.costItems) + ? clonePlainData(detail.costItems) + : Array.isArray(cost.costItems) + ? clonePlainData(cost.costItems) + : Array.isArray(cost.items) + ? clonePlainData(cost.items) + : Array.isArray(cost.resources) + ? clonePlainData(cost.resources) + : Array.isArray(costConclusion.costItems) + ? clonePlainData(costConclusion.costItems) + : Array.isArray(costConclusion.items) + ? clonePlainData(costConclusion.items) + : Array.isArray(costConclusion.resources) + ? clonePlainData(costConclusion.resources) + : [], + }; + } + + function candidateIndexFromSource(source) { + if (!source || typeof source !== "object") { + return null; + } + const data = source.data && typeof source.data === "object" ? source.data : {}; + const candidate = source.candidate && typeof source.candidate === "object" ? source.candidate : {}; + const rawIndex = + source.candidateIndex ?? + source.candidate_index ?? + source.optionIndex ?? + source.option_index ?? + candidate.index ?? + candidate.id ?? + candidate.candidateIndex ?? + candidate.candidate_index ?? + data.candidateIndex ?? + data.candidate_index ?? + data.optionIndex ?? + data.option_index ?? + null; + const normalizedIndex = normalizeCandidateIndexValue(rawIndex); + return normalizedIndex === "" || normalizedIndex === null || normalizedIndex === undefined ? null : normalizedIndex; + } + + function candidateSelectionInputKind(source) { + if (!source || typeof source !== "object") { + return ""; + } + return String(source.kind || source.inputKind || source.input_kind || source.type || ""); + } + + function hasCandidateSelectionOptions(source) { + const kind = candidateSelectionInputKind(source); + return (kind === "candidate_selection" || kind === "candidate_select") && Array.isArray(source.options); + } + + function isCandidateSubPipelineEvent(envelope, stepId) { + const eventType = eventTypeOf(envelope || {}); + const candidateIndex = candidateIndexFromSource(envelope); + if (candidateIndex === null || candidateIndex === undefined) { + return false; + } + if (String(eventType || "").startsWith("candidate_step")) { + return true; + } + if (eventType === "candidate_started" || eventType === "candidate_completed" || eventType === "candidate_failed") { + return true; + } + if (envelope.candidateStep || envelope.candidate_step) { + return true; + } + return ( + stepId === "evaluate_candidates" && + ["text_delta", "tool_result", "tool_use", "tool_call", "tool_started", "permission_requested"].includes(eventType) + ); + } + + function appendCandidateSubEventInPlace(state, envelope) { + const candidateIndex = candidateIndexFromSource(envelope); + if (candidateIndex === null || candidateIndex === undefined) { + return state; + } + upsertCandidateInPlace(state, { + candidateIndex, + name: + envelope && + envelope.candidate && + typeof envelope.candidate === "object" && + (envelope.candidate.name || envelope.candidate.title || envelope.candidate.label), + }); + const targetIndex = state.candidates.findIndex( + (candidate) => normalizeCandidateIndexValue(candidate.candidateIndex) === candidateIndex + ); + if (targetIndex < 0) { + return state; + } + const target = cloneCandidate(state.candidates[targetIndex]); + target.subEvents = Array.isArray(target.subEvents) ? target.subEvents : []; + target.subEvents.push(clonePlainData(envelope)); + target.subEvents = target.subEvents.slice(-MAX_CANDIDATE_SUB_EVENTS); + state.candidates[targetIndex] = target; + return state; + } + + function candidateCollectionsFromSource(source) { + if (!source || typeof source !== "object") { + return []; + } + const collections = []; + const collectFromObject = (target, options = {}) => { + if (!target || typeof target !== "object") { + return; + } + collections.push( + target.candidateDetails, + target.candidate_details, + target.candidates, + target.draftCandidates, + target.draft_candidates, + target.planCandidates, + target.plan_candidates, + target.candidateOptions, + target.candidate_options, + target.candidateSummaries, + target.candidate_summaries, + target.plans, + target.proposals + ); + if (options.includeGenericOptions) { + collections.push(target.options); + } + }; + const display = source.display && typeof source.display === "object" ? source.display : null; + if (display) { + collectFromObject(display, { includeGenericOptions: true }); + } + collectFromObject(source); + if (hasCandidateSelectionOptions(source)) { + collections.push(source.options); + } + const pendingInput = pendingInputOf(source); + if (pendingInput && typeof pendingInput === "object" && hasCandidateSelectionOptions(pendingInput)) { + collections.push(pendingInput.options); + } + const conclusion = source.conclusion && typeof source.conclusion === "object" ? source.conclusion : null; + if (conclusion) { + collectFromObject(conclusion, { includeGenericOptions: true }); + } + const data = source.data && typeof source.data === "object" ? source.data : null; + if (data && data !== source) { + collections.push(...candidateCollectionsFromSource(data)); + } + return collections.filter(Array.isArray); + } + + function numericConclusionItems(conclusion) { + if (!conclusion || typeof conclusion !== "object" || Array.isArray(conclusion)) { + return []; + } + return Object.keys(conclusion) + .filter((key) => /^\d+$/.test(key) && conclusion[key] && typeof conclusion[key] === "object") + .map((key) => ({ + index: Number(key), + candidateIndex: Number(key), + ...conclusion[key], + })); + } + + function upsertCandidatesFromSource(state, source) { + candidateCollectionsFromSource(source).forEach((collection) => { + collection.forEach((item) => { + upsertCandidateInPlace(state, candidateFromDisplayItem(item)); + }); + }); + const upsertNumericConclusionItems = (current) => { + const conclusion = current && current.conclusion && typeof current.conclusion === "object" ? current.conclusion : null; + numericConclusionItems(conclusion).forEach((item) => { + upsertCandidateInPlace(state, candidateFromDisplayItem(item)); + }); + const data = current && current.data && typeof current.data === "object" ? current.data : null; + if (data && data !== current) { + upsertNumericConclusionItems(data); + } + }; + upsertNumericConclusionItems(source); + return state; + } + + function candidateFromEnvelope(envelope) { + if (!envelope || typeof envelope !== "object") { + return null; + } + const data = envelope.data && typeof envelope.data === "object" ? envelope.data : {}; + const conclusion = data.conclusion && typeof data.conclusion === "object" ? data.conclusion : {}; + const detail = + data.detail && typeof data.detail === "object" + ? data.detail + : data.candidate_detail && typeof data.candidate_detail === "object" + ? data.candidate_detail + : {}; + const eventCandidate = envelope.candidate && typeof envelope.candidate === "object" ? envelope.candidate : {}; + const dataCandidate = data.candidate && typeof data.candidate === "object" ? data.candidate : {}; + const conclusionCandidate = + conclusion.candidate && typeof conclusion.candidate === "object" ? conclusion.candidate : {}; + const conclusions = data.conclusions && typeof data.conclusions === "object" ? data.conclusions : {}; + const templateConclusion = conclusions.template && typeof conclusions.template === "object" ? conclusions.template : {}; + const costConclusion = conclusions.cost && typeof conclusions.cost === "object" ? conclusions.cost : {}; + const candidateIndex = candidateIndexFromSource(envelope); + return candidateFromDisplayItem({ + ...data, + ...conclusion, + ...templateConclusion, + detail: Object.keys(detail).length ? detail : { ...conclusion, ...templateConclusion }, + cost: Object.keys(costConclusion).length ? costConclusion : data.cost, + candidate: { + ...eventCandidate, + ...dataCandidate, + ...conclusionCandidate, + }, + candidateIndex, + }); + } + + function hasCandidateValue(value) { + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== "" && value !== null && value !== undefined; + } + + function mergeCandidate(existing, candidate) { + const result = cloneCandidate(existing || {}); + Object.keys(candidate || {}).forEach((key) => { + const value = candidate[key]; + if (hasCandidateValue(value)) { + result[key] = clonePlainData(value); + } else if (!Object.prototype.hasOwnProperty.call(result, key)) { + result[key] = clonePlainData(value); + } + }); + return cloneCandidate(result); + } + + function upsertCandidateInPlace(state, candidate) { + if (!candidate) { + return state; + } + const nextCandidate = cloneCandidate(candidate); + nextCandidate.candidateIndex = normalizeCandidateIndexValue(nextCandidate.candidateIndex); + const hasNextIndex = nextCandidate.candidateIndex !== null && nextCandidate.candidateIndex !== undefined; + const index = state.candidates.findIndex((existing) => { + if (hasNextIndex && normalizeCandidateIndexValue(existing.candidateIndex) === nextCandidate.candidateIndex) { + return true; + } + if (existing.name && nextCandidate.name && existing.name === nextCandidate.name) { + return true; + } + return false; + }); + if (index >= 0) { + state.candidates[index] = mergeCandidate(state.candidates[index], nextCandidate); + } else { + state.candidates.push(nextCandidate); + } + return state; + } + + function upsertCandidate(state, candidate) { + const nextState = cloneState(state); + return upsertCandidateInPlace(nextState, candidate); + } + + function pendingInputFromSnapshot(snapshot) { + const pendingInput = pendingInputOf(snapshot); + if (!pendingInput) { + return null; + } + return pendingInputFromInput(pendingInput); + } + + function pendingInputFromInput(input) { + if (!input || typeof input !== "object") { + return null; + } + const pendingInput = clonePlainData(input); + return { + ...pendingInput, + prompt: pendingInput.prompt || pendingInput.question || "", + options: Array.isArray(pendingInput.options) ? pendingInput.options : [], + }; + } + + function applySnapshot(state, snapshot) { + if (!snapshot || typeof snapshot !== "object") { + return state; + } + const taskId = taskIdOf(snapshot); + if (taskId) { + state.pipelineTaskId = taskId; + } + const contextId = contextIdOf(snapshot); + if (contextId) { + state.contextId = contextId; + } + updateLastSequence(state, sequenceOf(snapshot)); + if (snapshot.status) { + state.status = normalizeStatus(snapshot.status); + if (state.status && state.status !== "idle") { + state.pipelineStarted = true; + } + } + + if (Array.isArray(snapshot.steps)) { + snapshot.steps.forEach((step) => { + const stepId = normalizeStepId(step); + if (stepId && state.steps[stepId]) { + const status = normalizeStatus(step.status) || state.steps[stepId].status; + state.steps[stepId].status = status; + if (status && status !== "pending") { + state.pipelineStarted = true; + } + if (status === "working" || status === "waiting_input") { + state.currentStepId = stepId; + } + } + }); + } + + upsertCandidatesFromSource(state, snapshot); + + const pendingInput = pendingInputFromSnapshot(snapshot); + if ( + Object.prototype.hasOwnProperty.call(snapshot, "pendingInput") || + Object.prototype.hasOwnProperty.call(snapshot, "pending_input") + ) { + state.pendingInput = pendingInputFromInput(pendingInputOf(snapshot)); + } else if (pendingInput) { + state.pendingInput = pendingInput; + } + + const normalHandoff = normalHandoffOf(snapshot); + if ( + normalHandoff && + typeof normalHandoff === "object" && + normalHandoff.action === "switch_to_normal" && + targetModeOf(normalHandoff) === "normal" + ) { + state.normalHandoffReady = true; + state.activeTaskId = ""; + } + return state; + } + + function currentStepIdFromState(state) { + const isActive = (stepId) => { + const status = stepStatusClass(normalizeStatus(state && state.steps && state.steps[stepId] && state.steps[stepId].status)); + return status === "working" || status === "waiting_input"; + }; + if (state && state.currentStepId && state.steps && state.steps[state.currentStepId] && isActive(state.currentStepId)) { + return state.currentStepId; + } + const activeStepId = STEP_ORDER.find((stepId) => isActive(stepId)); + return activeStepId || ""; + } + + function inferredStepIdForEvent(state, envelope, explicitStepId) { + if (explicitStepId) { + return explicitStepId; + } + if (!CURRENT_STEP_EVENT_TYPES.has(eventTypeOf(envelope))) { + return ""; + } + return currentStepIdFromState(state); + } + + function applyPipelineEnvelope(state, envelope) { + if (!envelope) { + return state; + } + const eventType = eventTypeOf(envelope); + const taskId = taskIdOf(envelope); + if (taskId) { + state.pipelineTaskId = taskId; + } + const contextId = contextIdOf(envelope); + if (contextId) { + state.contextId = contextId; + } + updateLastSequence(state, sequenceOf(envelope)); + if (envelope.status) { + state.status = normalizeStatus(envelope.status); + } + + const explicitStepId = normalizeStepId(envelope.step); + const stepId = inferredStepIdForEvent(state, envelope, explicitStepId); + if (eventType === "pipeline_started" || stepId) { + state.pipelineStarted = true; + } + if (stepId && state.steps[stepId]) { + state.currentStepId = stepId; + state.steps[stepId].status = + statusFromEventType(eventType, (envelope.step && envelope.step.status) || envelope.status) || + state.steps[stepId].status; + state.steps[stepId].events.push(clonePlainData(envelope)); + if (eventType === "step_completed" && state.expandedStepDetails) { + state.expandedStepDetails[stepId] = false; + } + } + if (isCandidateSubPipelineEvent(envelope, stepId)) { + appendCandidateSubEventInPlace(state, envelope); + } + + const data = envelope.data || {}; + if (eventType === "candidate_completed" || eventType === "candidate_failed") { + upsertCandidateInPlace(state, candidateFromEnvelope(envelope)); + const candidateIndex = candidateIndexFromSource(envelope); + if (candidateIndex !== null && candidateIndex !== undefined) { + state.expandedCandidateSubpipelines = state.expandedCandidateSubpipelines || {}; + state.expandedCandidateSubpipelines[String(candidateIndex)] = false; + } + } + if (eventType === "candidate_detail_shown") { + upsertCandidateInPlace( + state, + candidateFromDisplayItem({ + ...data, + candidate: envelope.candidate || data.candidate, + step: envelope.step || data.step, + }) + ); + } + upsertCandidatesFromSource(state, envelope); + if (eventType === "input_required") { + state.pendingInput = pendingInputFromInput(pendingInputOf(envelope) || data); + } + if (eventType === "input_received") { + state.pendingInput = null; + } + if ( + eventType === "pipeline_handoff_ready" || + (data.action === "switch_to_normal" && targetModeOf(data) === "normal") + ) { + state.normalHandoffReady = true; + state.activeTaskId = ""; + if (envelope.status) { + state.status = normalizeStatus(envelope.status); + } + } + return state; + } + + function isSnapshotLike(payload) { + if (!payload || typeof payload !== "object") { + return false; + } + if (eventTypeOf(payload)) { + return false; + } + return Boolean( + payload.display || + Object.prototype.hasOwnProperty.call(payload, "pendingInput") || + Object.prototype.hasOwnProperty.call(payload, "pending_input") || + normalHandoffOf(payload) || + taskIdOf(payload) || + contextIdOf(payload) || + sequenceOf(payload) !== null || + Array.isArray(payload.steps) + ); + } + + function reducePipelinePayload(state, payload) { + const nextState = cloneState(state); + const hasEvents = payload && Array.isArray(payload.events); + applyPipelineEnvelope(nextState, hasEvents ? null : extractPipelineEnvelope(payload)); + if (payload && payload.snapshot) { + applySnapshot(nextState, payload.snapshot); + } else if (isSnapshotLike(payload)) { + applySnapshot(nextState, payload); + } + if (payload && Array.isArray(payload.events)) { + payload.events.forEach((event) => { + applyPipelineEnvelope(nextState, extractPipelineEnvelope(event)); + }); + } + applyNormalChatPayload(nextState, payload); + return nextState; + } + + function a2aSource(payload) { + if (!payload || typeof payload !== "object") { + return null; + } + if (Array.isArray(payload)) { + for (const item of payload) { + const source = a2aSource(item); + if (source) { + return source; + } + } + return null; + } + if (payload.status && typeof payload.status === "object") { + return payload; + } + if (payload.metadata && typeof payload.metadata === "object") { + return payload; + } + for (const key of ["result", "params", "event", "task"]) { + if (payload[key] && typeof payload[key] === "object") { + const source = a2aSource(payload[key]); + if (source) { + return source; + } + } + } + return null; + } + + function a2aTaskId(source) { + return ( + taskIdOf(source || {}) || + valueOf(source || {}, "id") || + (source && source.task && typeof source.task === "object" && (taskIdOf(source.task) || source.task.id)) || + "" + ); + } + + function normalizeA2aState(value) { + if (!value) { + return ""; + } + const normalized = String(value) + .trim() + .toLowerCase() + .replace(/^task_state_/, "") + .replace(/-/g, "_"); + if (normalized === "input_required") { + return "completed"; + } + if (normalized === "completed" || normalized === "failed" || normalized === "canceled" || normalized === "working") { + return normalized; + } + return normalized; + } + + function partText(part) { + if (typeof part === "string") { + return part; + } + if (!part || typeof part !== "object") { + return ""; + } + if (typeof part.text === "string") { + return part.text; + } + if (part.root && typeof part.root === "object") { + return partText(part.root); + } + if (part.data && typeof part.data === "object" && typeof part.data.text === "string") { + return part.data.text; + } + return ""; + } + + function contentBlockText(block) { + if (typeof block === "string") { + return block; + } + if (!block || typeof block !== "object") { + return ""; + } + const type = String(block.type || block.kind || "").toLowerCase(); + if (type && type !== "text" && type !== "output_text") { + return ""; + } + if (typeof block.text === "string") { + return block.text; + } + if (typeof block.content === "string") { + return block.content; + } + return partText(block); + } + + function messageText(message) { + if (typeof message === "string") { + return message; + } + if (!message || typeof message !== "object") { + return ""; + } + if (typeof message.text === "string") { + return message.text; + } + if (Array.isArray(message.content)) { + return message.content.map(contentBlockText).join(""); + } + const parts = Array.isArray(message.parts) ? message.parts : []; + return parts.map(partText).join(""); + } + + function agentHistoryEntryText(source) { + const history = Array.isArray(source && source.history) + ? source.history + : Array.isArray(source && source.task && source.task.history) + ? source.task.history + : []; + for (let index = history.length - 1; index >= 0; index -= 1) { + const entry = history[index]; + const role = String((entry && entry.role) || "") + .toLowerCase() + .replace(/^role_/, ""); + if (!["agent", "assistant"].includes(role)) { + continue; + } + const text = messageText(entry); + if (text) { + return text; + } + } + return ""; + } + + function normalAnswerFromSource(source, status) { + const liveText = messageText((status && status.message) || (source && source.message)); + if (liveText) { + return { text: liveText, replace: false }; + } + const historyText = agentHistoryEntryText(source); + return historyText ? { text: historyText, replace: true } : { text: "", replace: false }; + } + + function mergeNormalAnswer(existing, next, replace) { + if (!next) { + return existing || ""; + } + if (!replace) { + return `${existing || ""}${next}`; + } + if (!existing) { + return next; + } + if (next.includes(existing) || existing.includes(next)) { + return next.length >= existing.length ? next : existing; + } + return `${existing}${next}`; + } + + function iacMetadata(source) { + const metadata = source && source.metadata && typeof source.metadata === "object" ? source.metadata : {}; + const statusMetadata = + source && source.status && source.status.metadata && typeof source.status.metadata === "object" + ? source.status.metadata + : {}; + return ( + metadata.iac_code || + metadata.iacCode || + metadata["iac-code"] || + statusMetadata.iac_code || + statusMetadata.iacCode || + statusMetadata["iac-code"] || + null + ); + } + + function compactValueText(value) { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (typeof value === "object") { + return ( + value.content || + value.text || + value.summary || + value.safeSummary || + value.message || + value.error || + "" + ); + } + return ""; + } + + function normalToolText(tool) { + if (!tool || typeof tool !== "object") { + return ""; + } + const statusMap = { + started: "开始", + input_delta: "输入中", + input_complete: "输入完成", + completed: "完成", + failed: "失败", + }; + const name = tool.name || tool.toolName || "工具"; + const status = statusMap[tool.status] || tool.status || ""; + const result = compactValueText(tool.result || tool.artifact || tool.input || tool.partialJson); + return [name, status, result].filter(Boolean).join(" "); + } + + function normalEventsFromMetadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return []; + } + const events = []; + if (metadata.thinking && typeof metadata.thinking === "object") { + const text = compactValueText(metadata.thinking.text || metadata.thinking); + if (text) { + events.push({ kind: "thinking", label: "思考", text }); + } + } + if (metadata.tool && typeof metadata.tool === "object") { + const text = normalToolText(metadata.tool); + if (text) { + events.push({ kind: "tool", label: "工具", text }); + } + } + if (metadata.permission && typeof metadata.permission === "object") { + const text = metadata.permission.toolName || metadata.permission.tool_name || "权限确认"; + events.push({ kind: "permission", label: "权限", text }); + } + if (metadata.error && typeof metadata.error === "object") { + const text = compactValueText(metadata.error.message || metadata.error.error || metadata.error); + if (text) { + events.push({ kind: "error", label: "异常", text }); + } + } + return events; + } + + function lastNormalUserMessageId(state) { + const messages = Array.isArray(state && state.userMessages) ? state.userMessages : []; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + const placement = userMessagePlacement(message); + if (placement.position === "after_normal_handoff") { + return userMessageKey(message, index); + } + } + return ""; + } + + function normalTurnForEvent(state, taskId, shouldCreate) { + state.normalTurns = Array.isArray(state.normalTurns) ? state.normalTurns : []; + const id = taskId || `normal-turn-${state.normalTurns.length + 1}`; + let index = state.normalTurns.findIndex((turn) => turn && (turn.taskId === taskId || turn.id === id)); + if (index < 0) { + if (!shouldCreate) { + return null; + } + const afterUserMessageId = state.pendingNormalUserMessageId || lastNormalUserMessageId(state); + state.normalTurns.push({ + id, + taskId, + afterUserMessageId, + status: "working", + answer: "", + events: [], + }); + state.pendingNormalUserMessageId = ""; + index = state.normalTurns.length - 1; + } + state.normalTurns[index].events = Array.isArray(state.normalTurns[index].events) ? state.normalTurns[index].events : []; + return state.normalTurns[index]; + } + + function applyNormalChatPayload(state, payload) { + if (!state || !state.normalHandoffReady) { + return state; + } + const pipelineEnvelope = extractPipelineEnvelope(payload); + if (pipelineEnvelope && eventTypeOf(pipelineEnvelope)) { + return state; + } + const source = a2aSource(payload); + if (!source) { + return state; + } + const status = source.status && typeof source.status === "object" ? source.status : {}; + const stateValue = normalizeA2aState(status.state || source.state || source.status); + const answer = normalAnswerFromSource(source, status); + const answerText = answer.text; + const events = normalEventsFromMetadata(iacMetadata(source)); + const taskId = a2aTaskId(source); + const shouldCreate = Boolean(answerText || events.length || stateValue === "working"); + const turn = normalTurnForEvent(state, taskId, shouldCreate); + if (!turn) { + return state; + } + if (taskId) { + turn.taskId = taskId; + } + if (answerText) { + turn.answer = mergeNormalAnswer(turn.answer, answerText, answer.replace); + } + events.forEach((event) => { + turn.events.push(clonePlainData(event)); + }); + turn.events = turn.events.slice(-80); + if (stateValue === "working") { + turn.status = "working"; + } else if (stateValue === "failed" || stateValue === "canceled") { + turn.status = stateValue; + } else if (stateValue) { + turn.status = "completed"; + } + return state; + } + + function buildStreamPayload(state, prompt) { + const source = state && typeof state === "object" ? state : {}; + return { + serverUrl: source.serverUrl || "", + cwd: source.cwd || "", + contextId: source.contextId || "", + taskId: source.normalHandoffReady ? "" : source.activeTaskId || source.pipelineTaskId || "", + prompt: prompt || "", + }; + } + + function selectCandidate(state, candidateIndex) { + const nextState = state && typeof state === "object" ? state : createInitialState(); + const numericIndex = Number(candidateIndex); + nextState.selectedCandidateIndex = Number.isFinite(numericIndex) ? numericIndex : null; + return nextState; + } + + function promptForSelectedCandidate(state) { + if (!state || state.selectedCandidateIndex === null || state.selectedCandidateIndex === undefined) { + return ""; + } + const numericIndex = Number(state.selectedCandidateIndex); + if (!Number.isFinite(numericIndex)) { + return ""; + } + return `选择方案${numericIndex}`; + } + + window.SellingConsoleReducers = { + STEP_ORDER, + STEP_LABELS, + createInitialState, + extractPipelineEnvelope, + normalizeStepId, + upsertCandidate, + reducePipelinePayload, + candidateFromDisplayItem, + pendingInputFromSnapshot, + buildStreamPayload, + selectCandidate, + promptForSelectedCandidate, + }; + + const STEP_DESCRIPTIONS = { + intent_parsing: "识别业务目标、地域、预算与部署约束。", + architecture_planning: "拆解网络、计算、存储与安全资源拓扑。", + evaluate_candidates: "比较规格、可用区、成本与运维复杂度。", + confirm_and_select: "确认推荐方案并准备转入标准部署流程。", + deploying: "复核资源清单、交付方式与后续部署动作。", + }; + const CONCLUSION_FIELD_LABELS = { + architecture: "架构", + budget: "预算", + intent: "需求", + isInfraIntent: "基础设施需求", + is_infra_intent: "基础设施需求", + objective: "目标", + plan: "方案", + reason: "原因", + recommendation: "推荐", + region: "地域", + scenario: "场景", + selectedOption: "已选方案", + selectedValue: "已选项", + summary: "总结", + }; + const STATUS_LABELS = { + idle: "等待输入", + pending: "未开始", + working: "进行中", + completed: "已完成", + waiting_input: "等待输入", + failed: "失败", + error: "失败", + }; + const PROGRESS_STATUS_LABELS = { + pending: "待开始", + working: "思考中", + completed: "完成", + waiting_input: "待确认", + failed: "失败", + error: "失败", + }; + const STEP_DETAIL_STATUS_LABELS = { + working: "思考中", + completed: "思考完成", + waiting_input: "等待确认", + failed: "执行失败", + error: "执行失败", + }; + const STEP_STATUS_CLASSES = new Set(["pending", "working", "completed", "waiting_input", "failed", "error"]); + + const controller = { + state: null, + bound: false, + progressAnimationFrame: null, + progressAnimationToken: 0, + progressRunTimer: 0, + progressWaitTimer: 0, + }; + + function hasDocument() { + return typeof document !== "undefined" && document !== null; + } + + function canCreateElements() { + return hasDocument() && typeof document.createElement === "function"; + } + + function query(selector) { + if (!hasDocument() || typeof document.querySelector !== "function") { + return null; + } + return document.querySelector(selector); + } + + function byId(id) { + if (!hasDocument()) { + return null; + } + if (typeof document.getElementById === "function") { + return document.getElementById(id); + } + return query(`#${id}`); + } + + function clearElement(element) { + if (!element) { + return; + } + if (typeof element.replaceChildren === "function") { + element.replaceChildren(); + return; + } + while (element.firstChild && typeof element.removeChild === "function") { + element.removeChild(element.firstChild); + } + if (!element.firstChild) { + element.textContent = ""; + } + } + + function appendChild(parent, child) { + if (parent && child && typeof parent.appendChild === "function") { + parent.appendChild(child); + } + } + + function createElement(tagName, className, text) { + if (!canCreateElements()) { + return null; + } + const svgTags = new Set(["svg", "path"]); + const element = + svgTags.has(tagName) && typeof document.createElementNS === "function" + ? document.createElementNS("http://www.w3.org/2000/svg", tagName) + : document.createElement(tagName); + if (className) { + if (typeof element.setAttribute === "function") { + element.setAttribute("class", className); + } else { + element.className = className; + } + } + if (text !== undefined && text !== null) { + element.textContent = String(text); + } + return element; + } + + function addClassName(element, className) { + if (!element || !className) { + return element; + } + const current = + (typeof element.getAttribute === "function" && element.getAttribute("class")) || element.className || ""; + const classes = new Set(String(current || "").split(/\s+/).filter(Boolean)); + String(className || "") + .split(/\s+/) + .filter(Boolean) + .forEach((item) => classes.add(item)); + const nextClassName = Array.from(classes).join(" "); + if (typeof element.setAttribute === "function") { + element.setAttribute("class", nextClassName); + } else { + element.className = nextClassName; + } + return element; + } + + function markMarkdownNode(element, kind) { + if (element && kind) { + element.setAttribute("data-markdown-node", kind); + } + return element; + } + + function safeMarkdownUrl(value) { + const url = String(value || "").trim(); + if (/^(https?:|mailto:)/i.test(url)) { + return url; + } + return ""; + } + + function appendInlineMarkdown(parent, text) { + if (!parent) { + return; + } + const source = String(text || ""); + const tokenPattern = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g; + let cursor = 0; + source.replace(tokenPattern, (match, _token, offset) => { + if (offset > cursor) { + appendChild(parent, createElement("span", "", source.slice(cursor, offset))); + } + if (match.startsWith("**")) { + appendChild(parent, markMarkdownNode(createElement("strong", "", match.slice(2, -2)), "strong")); + } else if (match.startsWith("`")) { + appendChild(parent, markMarkdownNode(createElement("code", "", match.slice(1, -1)), "code")); + } else { + const linkMatch = match.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + const link = createElement("a", "", linkMatch ? linkMatch[1] : match); + const href = linkMatch ? safeMarkdownUrl(linkMatch[2]) : ""; + if (link && href) { + link.setAttribute("href", href); + link.setAttribute("target", "_blank"); + link.setAttribute("rel", "noreferrer"); + } + appendChild(parent, markMarkdownNode(link, "a")); + } + cursor = offset + match.length; + return match; + }); + if (cursor < source.length) { + appendChild(parent, createElement("span", "", source.slice(cursor))); + } + } + + function markdownLines(value) { + return String(value || "") + .replace(/\r\n?/g, "\n") + .replace(/([^\n])\s+(\d+[.)]\s+)/g, "$1\n$2") + .split("\n"); + } + + function renderMarkdownText(value, className) { + const container = createElement("div", className || "markdown-text"); + if (container) { + container.setAttribute("data-markdown-rendered", "true"); + } + const lines = markdownLines(value); + let paragraph = []; + const flushParagraph = () => { + if (paragraph.length === 0) { + return; + } + const node = createElement("p"); + appendInlineMarkdown(node, paragraph.join(" ").trim()); + appendChild(container, node); + paragraph = []; + }; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + if (!trimmed) { + flushParagraph(); + continue; + } + if (/^[-*]\s+/.test(trimmed)) { + flushParagraph(); + const list = createElement("ul"); + while (index < lines.length && /^[-*]\s+/.test(lines[index].trim())) { + const item = markMarkdownNode(createElement("li"), "li"); + appendInlineMarkdown(item, lines[index].trim().replace(/^[-*]\s+/, "")); + appendChild(list, item); + index += 1; + } + index -= 1; + appendChild(container, list); + continue; + } + if (/^\d+[.)]\s+/.test(trimmed)) { + flushParagraph(); + const list = markMarkdownNode(createElement("ol"), "ol"); + while (index < lines.length && /^\d+[.)]\s+/.test(lines[index].trim())) { + const item = markMarkdownNode(createElement("li"), "li"); + appendInlineMarkdown(item, lines[index].trim().replace(/^\d+[.)]\s+/, "")); + appendChild(list, item); + index += 1; + } + index -= 1; + appendChild(container, list); + continue; + } + paragraph.push(trimmed); + } + flushParagraph(); + if (container && container.children.length === 0) { + appendChild(container, createElement("p", "", "")); + } + return container; + } + + function statusLabel(status) { + return STATUS_LABELS[status] || status || "等待输入"; + } + + function stepStatusClass(status) { + return STEP_STATUS_CLASSES.has(status) ? status : "pending"; + } + + function progressStatusLabel(status) { + return PROGRESS_STATUS_LABELS[status] || statusLabel(status); + } + + function stepDetailStatusLabel(status) { + return STEP_DETAIL_STATUS_LABELS[status] || statusLabel(status); + } + + function stepStateIcon(status) { + const icons = { + completed: "✓", + error: "!", + failed: "!", + waiting_input: "?", + working: "…", + }; + return icons[status] || ""; + } + + function stepIsVisible(step) { + const status = stepStatusClass(normalizeStatus(step && step.status) || "pending"); + return status !== "pending" || (Array.isArray(step && step.events) && step.events.length > 0); + } + + function stepIsOpen(status) { + return status === "working" || status === "waiting_input"; + } + + function eventData(event) { + return event && event.data && typeof event.data === "object" ? event.data : {}; + } + + function firstTextValue(source, keys) { + if (!source || typeof source !== "object") { + return ""; + } + for (const key of keys) { + const value = source[key]; + if (value === 0 || value) { + return String(value); + } + } + return ""; + } + + function friendlyFieldLabel(key) { + return CONCLUSION_FIELD_LABELS[key] || key.replace(/_/g, " "); + } + + function friendlyValue(value) { + if (value === true) { + return "是"; + } + if (value === false) { + return "否"; + } + if (Array.isArray(value)) { + return value + .map((item) => { + if (item && typeof item === "object") { + return firstTextValue(item, ["title", "name", "label", "summary", "description"]); + } + return item === 0 || item ? String(item) : ""; + }) + .filter(Boolean) + .slice(0, 3) + .join("、"); + } + if (value && typeof value === "object") { + return conclusionText(value); + } + return value === 0 || value ? String(value) : ""; + } + + function optionsConclusionText(options) { + if (!Array.isArray(options) || options.length === 0) { + return ""; + } + const names = options + .map((option) => { + if (option && typeof option === "object") { + return firstTextValue(option, ["title", "name", "label", "candidateName"]); + } + return option === 0 || option ? String(option) : ""; + }) + .filter(Boolean) + .slice(0, 2); + return names.length > 0 ? `已生成 ${options.length} 个方案:${names.join("、")}` : `已生成 ${options.length} 个方案`; + } + + function conclusionText(conclusion) { + if (conclusion === 0 || conclusion) { + if (typeof conclusion !== "object") { + return String(conclusion); + } + } else { + return ""; + } + const direct = firstTextValue(conclusion, [ + "summary", + "title", + "description", + "text", + "result", + "decision", + "recommendation", + "selectedOption", + "selectedValue", + ]); + if (direct) { + return direct; + } + const optionsText = optionsConclusionText(conclusion.options || conclusion.candidates || conclusion.candidateDetails); + if (optionsText) { + return optionsText; + } + const numericItems = numericConclusionItems(conclusion); + if (numericItems.length > 0) { + return `已完成 ${numericItems.length} 个方案评估`; + } + return Object.keys(conclusion) + .filter((key) => !["options", "candidates", "candidateDetails"].includes(key)) + .map((key) => { + const value = friendlyValue(conclusion[key]); + return value ? `${friendlyFieldLabel(key)}:${value}` : ""; + }) + .filter(Boolean) + .join(","); + } + + function conclusionOptionItems(conclusion) { + if (!conclusion || typeof conclusion !== "object") { + return []; + } + const options = conclusion.options || conclusion.candidates || conclusion.candidateDetails; + if (Array.isArray(options)) { + return options; + } + return numericConclusionItems(conclusion); + } + + function conclusionFieldEntries(conclusion) { + if (!conclusion || typeof conclusion !== "object" || Array.isArray(conclusion)) { + return []; + } + if ( + firstTextValue(conclusion, [ + "summary", + "title", + "description", + "text", + "result", + "decision", + "recommendation", + "selectedOption", + "selectedValue", + ]) + ) { + return []; + } + if (optionsConclusionText(conclusion.options || conclusion.candidates || conclusion.candidateDetails)) { + return []; + } + return Object.keys(conclusion) + .filter((key) => !["options", "candidates", "candidateDetails"].includes(key)) + .map((key) => { + const value = friendlyValue(conclusion[key]); + return value ? { key, label: friendlyFieldLabel(key), value } : null; + }) + .filter(Boolean); + } + + function latestStepCompletion(step) { + const events = Array.isArray(step && step.events) ? step.events : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + const data = eventData(event); + const conclusion = data.conclusion || event.conclusion; + const text = conclusionText(conclusion) || firstTextValue(data, ["summary", "statusMessage", "text", "errorSummary"]); + if (conclusion || text) { + return { conclusion, text }; + } + } + return { conclusion: null, text: "已完成本步骤。" }; + } + + function completionTextForStep(step) { + return latestStepCompletion(step).text || "已完成本步骤。"; + } + + function eventText(event) { + const data = eventData(event); + const eventType = eventTypeOf(event || {}); + const text = + firstTextValue(data, ["summary", "text", "statusMessage", "question", "prompt", "candidateName", "errorSummary"]) || + conclusionText(data.conclusion || event.conclusion); + if (text) { + return text; + } + if (eventType === "step_started") { + return "开始思考"; + } + if (eventType === "input_required") { + return "等待您确认或补充信息"; + } + if (eventType === "candidate_detail_shown") { + return "生成候选方案详情"; + } + if (eventType === "permission_requested") { + return "等待权限确认"; + } + return eventType || "收到新事件"; + } + + function compactText(value, maxLength = 180) { + if (value === "" || value === null || value === undefined) { + return ""; + } + const text = String(value).replace(/\s+/g, " ").trim(); + return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; + } + + function summarizeValue(value, maxLength = 180) { + if (value === "" || value === null || value === undefined) { + return ""; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return compactText(value, maxLength); + } + try { + return compactText(JSON.stringify(value), maxLength); + } catch (_error) { + return compactText(value, maxLength); + } + } + + function toolNameFromEvent(event) { + const data = eventData(event); + return data.toolName || data.tool_name || data.name || (data.tool && data.tool.name) || ""; + } + + function objectHasKeys(value) { + return Boolean(value && typeof value === "object" && Object.keys(value).length > 0); + } + + function toolSummaryFromEvent(event) { + const data = eventData(event); + const result = data.result && typeof data.result === "object" ? data.result : {}; + const directSummary = + firstTextValue(data, ["safeSummary", "safe_summary", "summary", "text", "statusMessage", "message"]) || + firstTextValue(result, ["safeSummary", "safe_summary", "summary", "message", "content", "text"]); + if (directSummary) { + return directSummary; + } + const stackId = data.stackId || data.stack_id || result.stackId || result.stack_id; + const stackStatus = data.stackStatus || data.stack_status || result.stackStatus || result.stack_status; + const resourceId = data.resourceId || data.resource_id || result.resourceId || result.resource_id; + const resourceName = data.resourceName || data.resource_name || result.resourceName || result.resource_name; + const status = data.statusMessage || data.statusText || data.status || result.status || ""; + const parts = [stackId, stackStatus, resourceName, resourceId, status] + .map((part) => compactText(part, 80)) + .filter(Boolean); + if (parts.length > 0) { + return parts.join(" · "); + } + if (objectHasKeys(result)) { + return summarizeValue(result, 120); + } + return data.action || ""; + } + + function stepEventKind(event) { + const data = eventData(event); + const eventType = eventTypeOf(event || {}); + const type = data.type || eventType || ""; + if (type === "tool_result" || eventType === "tool_result") { + return "tool_result"; + } + if (type === "tool_use" || eventType === "tool_use" || eventType === "tool_call" || eventType === "tool_started") { + return "tool_use"; + } + if (eventType === "input_required") { + return "input_required"; + } + if (eventType === "candidate_detail_shown") { + return "candidate_detail"; + } + if (eventType === "permission_requested") { + return "permission"; + } + if (eventType === "text_delta") { + return "text_delta"; + } + return eventType || "event"; + } + + function textDeltaText(event) { + const data = eventData(event); + return firstTextValue(data, ["text", "delta", "content", "summary"]); + } + + function textDeltaMergeKey(event) { + const candidateIndex = candidateIndexFromSource(event); + const subStep = candidateSubStepOf(event); + const subStepId = subStep.id || subStep.stepId || subStep.name || subStep.label || ""; + return `${candidateIndex === null || candidateIndex === undefined ? "" : candidateIndex}|${subStepId}`; + } + + function compactDisplayEvents(events) { + return (Array.isArray(events) ? events : []).reduce((result, event) => { + const kind = stepEventKind(event); + if (kind !== "text_delta") { + result.push(clonePlainData(event)); + return result; + } + const fragment = textDeltaText(event); + const previous = result[result.length - 1]; + if (previous && stepEventKind(previous) === "text_delta" && textDeltaMergeKey(previous) === textDeltaMergeKey(event)) { + previous.data = previous.data && typeof previous.data === "object" ? previous.data : {}; + previous.data.text = `${textDeltaText(previous)}${fragment}`; + } else { + const nextEvent = clonePlainData(event); + nextEvent.data = nextEvent.data && typeof nextEvent.data === "object" ? nextEvent.data : {}; + nextEvent.data.text = fragment; + result.push(nextEvent); + } + return result; + }, []); + } + + function stepEventLabel(kind) { + const labels = { + candidate_detail: "方案详情", + input_required: "等待输入", + permission: "权限确认", + step_started: "步骤开始", + text_delta: "思考片段", + tool_result: "工具结果", + tool_use: "工具调用", + }; + return labels[kind] || kind.replace(/_/g, " "); + } + + function eventTitle(event) { + const data = eventData(event); + const kind = stepEventKind(event); + if (kind === "tool_result" || kind === "tool_use") { + return toolNameFromEvent(event) || "工具"; + } + if (kind === "input_required") { + return firstTextValue(data, ["question", "prompt", "summary"]) || "等待您确认或补充信息"; + } + if (kind === "candidate_detail") { + const detail = data.detail && typeof data.detail === "object" ? data.detail : data; + return firstTextValue(detail, ["candidateName", "name", "title"]) || "生成候选方案详情"; + } + return eventText(event); + } + + function eventMetaEntries(event) { + const data = eventData(event); + const kind = stepEventKind(event); + if (kind === "tool_result" || kind === "tool_use") { + return [ + ["摘要", toolSummaryFromEvent(event)], + ["地域", data.regionId || data.region_id], + ]; + } + if (kind === "input_required") { + return [["类型", data.kind], ["选项", Array.isArray(data.options) ? `${data.options.length} 个` : ""]]; + } + if (kind === "permission") { + return [["工具", data.toolName || data.tool_name], ["原因", data.reason || data.safeSummary]]; + } + return []; + } + + function appendKeyValueList(parent, entries, className) { + const filteredEntries = entries + .map(([label, value]) => [label, summarizeValue(value)]) + .filter(([_label, value]) => value); + if (filteredEntries.length === 0) { + return; + } + const list = createElement("dl", className || "key-value-list"); + filteredEntries.forEach(([label, value]) => { + const row = createElement("div"); + appendChild(row, createElement("dt", "", `${label}:`)); + appendChild(row, createElement("dd", "", value)); + appendChild(list, row); + }); + appendChild(parent, list); + } + + function renderStepEvent(event) { + const kind = stepEventKind(event); + const item = createElement("li", `step-event-card ${kind}`); + if (item) { + item.setAttribute("data-step-event-kind", kind); + } + appendChild(item, createElement("span", "step-event-label", stepEventLabel(kind))); + appendChild(item, createElement("p", "step-event-title", eventTitle(event))); + appendKeyValueList(item, eventMetaEntries(event), "step-event-meta"); + return item; + } + + function renderStepProcess(detail, step) { + const events = compactDisplayEvents(Array.isArray(step && step.events) ? step.events : []); + if (events.length === 0) { + return; + } + const process = createElement("details", "step-process"); + if (process) { + process.setAttribute("data-step-process", step.id || ""); + } + const head = createElement("summary", "step-process-head"); + appendChild(head, createElement("strong", "", "思考过程")); + appendChild(head, createElement("span", "", `${events.length} 条事件`)); + appendChild(process, head); + const eventList = createElement("ul", "step-event-list step-process-events"); + events.forEach((event) => { + const item = renderStepEvent(event); + if (item) { + item.setAttribute("data-step-process-event", stepEventKind(event)); + } + appendChild(eventList, item); + }); + appendChild(process, eventList); + appendChild(detail, process); + } + + function renderStepResult(detail, step) { + const completion = latestStepCompletion(step); + const options = conclusionOptionItems(completion.conclusion); + if (options.length > 0) { + const list = createElement("div", "step-result-options"); + options.forEach((option, index) => { + const candidate = candidateFromDisplayItem(option); + const candidateIndex = candidateIndexOf(candidate, index); + const item = createElement("article", "step-result-option"); + if (item) { + item.setAttribute("data-step-result-option", String(candidateIndex)); + } + appendChild(item, createElement("strong", "", candidate.name || `方案 ${candidateIndex}`)); + if (candidate.summary) { + appendChild(item, createElement("span", "", candidate.summary)); + } + if (candidate.template && candidate.template !== candidate.summary && candidate.template !== candidate.name) { + appendChild(item, createElement("span", "", candidate.template)); + } + if (candidate.totalMonthlyCost !== "" && candidate.totalMonthlyCost !== null && candidate.totalMonthlyCost !== undefined) { + appendChild(item, createElement("span", "price", candidate.totalMonthlyCost)); + } + if (candidate.outputPath) { + appendChild(item, createElement("span", "template-path", `模板:${candidate.outputPath}`)); + } + appendChild(list, item); + }); + appendChild(detail, list); + return; + } + const entries = conclusionFieldEntries(completion.conclusion); + if (entries.length > 0) { + const list = createElement("dl", "step-result-list"); + entries.forEach((entry) => { + const row = createElement("div"); + if (row) { + row.setAttribute("data-step-result-field", entry.key); + } + appendChild(row, createElement("dt", "", `${entry.label}:`)); + appendChild(row, createElement("dd", "", entry.value)); + appendChild(list, row); + }); + appendChild(detail, list); + return; + } + appendChild(detail, createElement("p", "step-result", completion.text || "已完成本步骤。")); + } + + function candidateResultSummary(candidate) { + return ( + (candidate && (candidate.summary || candidate.template || candidate.description || candidate.pros)) || + "方案摘要已生成,可在右侧查看完整方案。" + ); + } + + function isTemplateLikeText(value) { + const text = String(value || ""); + if (!text) { + return false; + } + return ( + /ROSTemplateFormatVersion|ALIYUN::|Resources:\s|Parameters:\s|Metadata:\s/.test(text) || + (text.length > 240 && /Type:\s|Properties:\s|Description:\s/.test(text)) + ); + } + + function candidateResultSummaryDisplay(candidate) { + const rawSummary = candidateResultSummary(candidate); + const templateText = candidate && isTemplateLikeText(candidate.template) ? String(candidate.template) : ""; + if (isTemplateLikeText(rawSummary)) { + return { + text: "模板内容已生成,悬浮查看完整模板。", + template: String(rawSummary), + }; + } + const compactSummary = compactText(rawSummary, 140); + return { + text: compactSummary, + template: templateText, + title: compactSummary !== String(rawSummary || "") ? String(rawSummary || "") : "", + }; + } + + function attachTemplatePopover(host, templateText) { + if (!host || !templateText) { + return host; + } + addClassName(host, "template-popover-host"); + const popover = createElement("div", "template-popover"); + if (popover) { + popover.setAttribute("data-template-popover", "true"); + popover.setAttribute("role", "tooltip"); + popover.setAttribute("tabindex", "0"); + } + appendChild(popover, createElement("div", "template-popover-title", "模板内容")); + appendChild(popover, createElement("pre", "", templateText)); + appendChild(host, popover); + return host; + } + + function renderCandidateProcess(process, candidate, candidateIndex) { + const events = compactDisplayEvents(Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : []); + const renderableEvents = candidateRenderableSubEvents(events); + if (renderableEvents.length === 0) { + return; + } + const details = createElement("details", "step-candidate-result-process"); + if (details) { + details.setAttribute("data-step-candidate-result-process", String(candidateIndex)); + details.open = false; + } + const head = createElement("summary", "step-process-head"); + appendChild(head, createElement("strong", "", "思考过程")); + const groups = groupCandidateSubEvents(renderableEvents, { forceComplete: candidateEvaluationIsComplete() }); + appendChild(head, createElement("span", "", `${groups.length} 个子步骤`)); + appendChild(details, head); + const body = createElement("div", "step-candidate-result-process-body"); + const substeps = createElement("div", "candidate-substeps"); + groups.forEach((group) => { + appendChild(substeps, renderCandidateSubstepGroup(group)); + }); + appendChild(body, substeps); + appendChild(details, body); + appendChild(process, details); + } + + function renderStepCandidateResults(detail, step) { + if (!step || step.id !== "evaluate_candidates") { + return false; + } + const state = ensureState(); + const candidates = Array.isArray(state.candidates) ? state.candidates : []; + if (candidates.length === 0) { + return false; + } + const list = createElement("div", "step-candidate-result-list"); + candidates.forEach((candidate, index) => { + const candidateIndex = candidateIndexOf(candidate, index); + const item = createElement("article", "step-candidate-result"); + if (item) { + item.setAttribute("data-step-candidate-result", String(candidateIndex)); + } + const summary = candidateResultSummaryDisplay(candidate); + const head = createElement("div", "step-candidate-result-head"); + appendChild(head, createElement("strong", "", `方案 ${candidateIndex}`)); + appendChild(head, createElement("span", "", candidate.name || `方案 ${candidateIndex}`)); + appendChild(item, head); + appendChild(item, createElement("span", "step-candidate-result-label", "评估结论")); + const summaryNode = createElement("p", "step-candidate-result-summary", summary.text); + if (summaryNode) { + summaryNode.setAttribute("data-step-candidate-result-summary", String(candidateIndex)); + } + appendChild(item, summaryNode); + if (candidate.template && candidate.template !== candidate.summary && !isTemplateLikeText(candidate.template)) { + appendChild(item, createElement("span", "step-candidate-result-template", candidate.template)); + } + if (candidate.totalMonthlyCost !== "" && candidate.totalMonthlyCost !== null && candidate.totalMonthlyCost !== undefined) { + appendChild(item, createElement("span", "step-candidate-result-price", candidate.totalMonthlyCost)); + } + renderCandidateProcess(item, candidate, candidateIndex); + attachTemplatePopover(item, summary.template); + appendChild(list, item); + }); + appendChild(detail, list); + return true; + } + + function candidateProgressText(event) { + const kind = candidateSubEventKind(event); + if (kind === "tool_result" || kind === "tool_use") { + return { label: candidateSubEventLabel(kind), title: eventTitle(event) }; + } + if (String(kind || "").startsWith("candidate_step")) { + return { label: candidateSubStepLabel(event), title: eventTitle(event) }; + } + return { label: stepEventLabel(kind), title: eventTitle(event) }; + } + + function renderStepCandidateProgress(detail) { + const state = ensureState(); + const candidates = Array.isArray(state.candidates) ? state.candidates : []; + const rows = candidates + .map((candidate, index) => { + const events = compactDisplayEvents(Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : []); + return { candidate, candidateIndex: candidateIndexOf(candidate, index), event: events[events.length - 1] }; + }) + .filter((row) => row.event); + if (rows.length === 0) { + return false; + } + const list = createElement("div", "step-candidate-progress-list"); + rows.forEach((row) => { + const item = createElement("article", "step-candidate-progress"); + const progress = candidateProgressText(row.event); + const head = createElement("div", "step-candidate-progress-head"); + if (item) { + item.setAttribute("data-step-candidate-progress", String(row.candidateIndex)); + } + if (head) { + head.setAttribute("data-step-candidate-progress-head", String(row.candidateIndex)); + } + appendChild(head, createElement("strong", "", `方案 ${row.candidateIndex}`)); + appendChild(head, createElement("span", "", row.candidate.name || `方案 ${row.candidateIndex}`)); + appendChild(item, head); + appendChild(item, createElement("span", "", progress.label)); + appendChild(item, createElement("p", "", progress.title)); + appendChild(list, item); + }); + appendChild(detail, list); + return true; + } + + function stepCanToggle(status) { + return status === "completed"; + } + + function stepDetailsExpanded(stepId, status) { + const state = ensureState(); + return stepCanToggle(status) && Boolean(state.expandedStepDetails && state.expandedStepDetails[stepId]); + } + + function toggleStepDetails(stepId) { + const state = ensureState(); + state.expandedStepDetails = state.expandedStepDetails || {}; + state.expandedStepDetails[stepId] = !Boolean(state.expandedStepDetails[stepId]); + renderAll(); + } + + function renderStepDetails(card, step, status, expanded) { + if (stepCanToggle(status) && !expanded) { + return; + } + const detail = createElement("div", "step-detail"); + + if (stepIsOpen(status)) { + const badge = createElement("span", "step-status", stepDetailStatusLabel(status)); + appendChild(detail, badge); + if (status === "waiting_input") { + const state = ensureState(); + renderPendingInputCard(detail, state); + renderStepProcess(detail, step); + appendChild(card, detail); + return; + } + const handledByCandidateSummary = step.id === "evaluate_candidates" && renderStepCandidateProgress(detail); + if (!handledByCandidateSummary) { + const events = compactDisplayEvents(Array.isArray(step.events) ? step.events : []); + const eventList = createElement("ul", "step-event-list"); + if (eventList) { + eventList.setAttribute("data-step-event-list", step.id || ""); + } + events.forEach((event) => { + appendChild(eventList, renderStepEvent(event)); + }); + if (events.length === 0) { + appendChild(eventList, createElement("li", "step-event-card", STEP_DESCRIPTIONS[step.id] || "正在处理当前步骤")); + } + appendChild(detail, eventList); + scrollElementToBottom(eventList); + } + } else if (status === "completed" && expanded) { + if (!renderStepCandidateResults(detail, step)) { + renderStepResult(detail, step); + renderStepProcess(detail, step); + } + } else if (status === "failed" || status === "error") { + const badge = createElement("span", "step-status", stepDetailStatusLabel(status)); + appendChild(detail, badge); + renderStepResult(detail, step); + renderStepProcess(detail, step); + } + appendChild(card, detail); + } + + function candidateChoiceText(candidate, fallbackIndex) { + const candidateIndex = candidateIndexOf(candidate, fallbackIndex); + const name = candidate && candidate.name ? candidate.name : `方案 ${candidateIndex}`; + const summary = candidate && candidate.summary ? candidate.summary : ""; + const price = presentValue(candidate && candidate.totalMonthlyCost, ""); + return `${name}${summary}${price}`; + } + + function pendingInputIsCandidateSelection(pendingInput) { + if (!pendingInput || typeof pendingInput !== "object") { + return false; + } + const kind = pendingInput.kind || ""; + return kind === "candidate_selection" || kind === "candidate_select"; + } + + function candidatesForPendingSelection(state) { + const pendingInput = state && state.pendingInput; + if (!pendingInputIsCandidateSelection(pendingInput)) { + return []; + } + const candidates = Array.isArray(state.candidates) ? state.candidates : []; + if (candidates.length > 0) { + return candidates; + } + return Array.isArray(pendingInput.options) ? pendingInput.options.map(candidateFromDisplayItem).filter(Boolean) : []; + } + + function renderCandidateChoiceList(parent, state) { + const candidates = candidatesForPendingSelection(state); + if (candidates.length === 0) { + return false; + } + const list = createElement("div", "candidate-choice-list"); + candidates.forEach((candidate, index) => { + const candidateIndex = candidateIndexOf(candidate, index); + const isSelected = state.selectedCandidateIndex === candidateIndex; + const choice = createElement("button", `candidate-choice${isSelected ? " selected" : ""}`); + if (choice) { + choice.setAttribute("type", "button"); + choice.setAttribute("data-candidate-choice", String(candidateIndex)); + choice.setAttribute("aria-pressed", isSelected ? "true" : "false"); + choice.addEventListener("click", () => { + controller.state = selectCandidate(ensureState(), candidateIndex); + syncComposerWithSelectedCandidate(controller.state); + renderAll(); + }); + } + appendChild(choice, createElement("strong", "", candidate.name || `方案 ${candidateIndex}`)); + const summary = candidate.summary || candidate.template || ""; + if (summary) { + appendChild(choice, createElement("span", "", summary)); + } + if (candidate.totalMonthlyCost !== "" && candidate.totalMonthlyCost !== null && candidate.totalMonthlyCost !== undefined) { + appendChild(choice, createElement("span", "price", candidate.totalMonthlyCost)); + } + appendChild(list, choice); + }); + appendChild(parent, list); + return true; + } + + function pendingInputKindLabel(kind) { + if (kind === "candidate_selection" || kind === "candidate_select") { + return "请选择方案"; + } + if (kind === "ask_user_question") { + return "需要您确认"; + } + return "需要您处理"; + } + + function pendingInputPrompt(pendingInput) { + return pendingInput && (pendingInput.prompt || pendingInput.question || pendingInput.freeTextPrompt || pendingInput.free_text_prompt) + ? pendingInput.prompt || pendingInput.question || pendingInput.freeTextPrompt || pendingInput.free_text_prompt + : "请补充信息后继续。"; + } + + function pendingOptionId(option, index) { + const rawId = option && (option.id ?? option.value ?? option.candidateIndex ?? option.candidate_index ?? index); + return rawId === null || rawId === undefined ? String(index) : String(rawId); + } + + function candidateIndexFromPendingOption(option, index) { + if (option && typeof option === "object") { + const nestedCandidate = option.candidate && typeof option.candidate === "object" ? option.candidate : {}; + const rawCandidateIndex = + option.candidateIndex ?? + option.candidate_index ?? + option.optionIndex ?? + option.option_index ?? + nestedCandidate.index ?? + nestedCandidate.candidateIndex ?? + nestedCandidate.candidate_index ?? + null; + if (rawCandidateIndex !== null && rawCandidateIndex !== undefined && rawCandidateIndex !== "") { + const numericIndex = Number(rawCandidateIndex); + return Number.isFinite(numericIndex) ? numericIndex : rawCandidateIndex; + } + } + const optionId = pendingOptionId(option, index); + const numericOptionId = Number(optionId); + return Number.isFinite(numericOptionId) ? numericOptionId : null; + } + + function pendingOptionLabel(option, index) { + if (!option || typeof option !== "object") { + return option === 0 || option ? String(option) : `选项 ${index + 1}`; + } + return option.label || option.title || option.name || option.candidateName || `选项 ${index + 1}`; + } + + function pendingOptionDescription(option) { + if (!option || typeof option !== "object") { + return ""; + } + return [option.description || option.summary || "", option.totalMonthlyCost || option.total_monthly_cost || option.price || ""] + .filter(Boolean) + .join(""); + } + + function syncComposerWithSelectedCandidate(state) { + const composer = byId("composer-input"); + if (composer && "value" in composer) { + composer.value = promptForSelectedCandidate(state || ensureState()); + } + } + + function handlePendingInputOption(option, index) { + const state = ensureState(); + const pendingInput = state.pendingInput || {}; + const kind = pendingInput.kind || ""; + const optionId = pendingOptionId(option, index); + const candidateIndex = candidateIndexFromPendingOption(option, index); + const composer = byId("composer-input"); + state.selectedPendingInputOptionId = optionId; + if (kind === "candidate_selection" || kind === "candidate_select") { + if (candidateIndex !== null && candidateIndex !== undefined) { + controller.state = selectCandidate(state, candidateIndex); + controller.state.selectedPendingInputOptionId = optionId; + syncComposerWithSelectedCandidate(controller.state); + } else if (composer && "value" in composer) { + composer.value = optionId || pendingOptionLabel(option, index); + } + renderAll(); + return; + } + if (candidateIndex !== null && candidateIndex !== undefined) { + controller.state = selectCandidate(state, candidateIndex); + controller.state.selectedPendingInputOptionId = optionId; + } + if (composer && "value" in composer) { + composer.value = optionId || pendingOptionLabel(option, index); + } + renderAll(); + } + + function renderPendingInputCard(parent, state) { + const pendingInput = state && state.pendingInput; + if (!pendingInput) { + return; + } + const kind = pendingInput.kind || "input"; + const isCandidateSelection = pendingInputIsCandidateSelection(pendingInput); + const card = createElement("section", "pending-input-card"); + if (card) { + card.setAttribute("data-pending-input-kind", kind); + } + appendChild(card, createElement("h2", "", pendingInputKindLabel(kind))); + appendChild(card, renderMarkdownText(pendingInputPrompt(pendingInput), "pending-input-prompt")); + const options = Array.isArray(pendingInput.options) ? pendingInput.options : []; + if (options.length > 0) { + const optionList = createElement("div", "pending-input-options"); + options.forEach((option, index) => { + const optionId = pendingOptionId(option, index); + const candidateIndex = candidateIndexFromPendingOption(option, index); + const isSelected = + state.selectedPendingInputOptionId === optionId || + (candidateIndex !== null && candidateIndex !== undefined && state.selectedCandidateIndex === candidateIndex); + const optionButton = createElement("button", `pending-input-option${isSelected ? " selected" : ""}`); + if (optionButton) { + optionButton.setAttribute("type", "button"); + optionButton.setAttribute("data-pending-input-option", optionId); + optionButton.setAttribute("aria-pressed", isSelected ? "true" : "false"); + if (candidateIndex !== null && candidateIndex !== undefined) { + optionButton.setAttribute("data-candidate-choice", String(candidateIndex)); + } + optionButton.addEventListener("click", () => handlePendingInputOption(option, index)); + } + appendChild(optionButton, createElement("strong", "", pendingOptionLabel(option, index))); + const description = pendingOptionDescription(option); + if (description) { + appendChild(optionButton, renderMarkdownText(description, "pending-input-option-description")); + } + appendChild(optionList, optionButton); + }); + appendChild(card, optionList); + } + appendChild(parent, card); + } + + function ensureState() { + if (!controller.state) { + const defaults = + window.SELLING_CONSOLE_DEFAULTS && typeof window.SELLING_CONSOLE_DEFAULTS === "object" + ? window.SELLING_CONSOLE_DEFAULTS + : {}; + controller.state = createInitialState(defaults); + } + return controller.state; + } + + function syncConnectionControlsFromState() { + const state = ensureState(); + const serverInput = byId("server-url"); + const cwdInput = byId("cwd"); + if (serverInput && "value" in serverInput && !serverInput.value && state.serverUrl) { + serverInput.value = state.serverUrl; + } + if (cwdInput && "value" in cwdInput && !cwdInput.value && state.cwd) { + cwdInput.value = state.cwd; + } + } + + function syncStateFromConnectionControls() { + const state = ensureState(); + const serverInput = byId("server-url"); + const cwdInput = byId("cwd"); + if (serverInput && "value" in serverInput) { + state.serverUrl = String(serverInput.value || "").trim(); + } + if (cwdInput && "value" in cwdInput) { + state.cwd = String(cwdInput.value || "").trim(); + } + return state; + } + + function renderStatus() { + const state = ensureState(); + const statusPill = byId("status-pill"); + if (statusPill) { + statusPill.textContent = statusLabel(state.pendingInput ? "waiting_input" : state.status); + } + } + + function stepModelsForProgress(state, ui, options = {}) { + const steps = STEP_ORDER.map((stepId, index) => { + const step = state.steps && state.steps[stepId] ? state.steps[stepId] : createSteps()[stepId]; + const status = stepStatusClass(normalizeStatus(step.status) || "pending"); + return { + id: stepId, + index, + label: step.label || STEP_LABELS[stepId] || stepId, + status, + }; + }); + if (options.useConfiguredActiveStep && ui && Number.isInteger(ui.activeStepIndex)) { + return { steps, activeIndex: ui.activeStepIndex }; + } + const currentIndex = steps.findIndex((step) => step.status === "working" || step.status === "waiting_input"); + if (currentIndex >= 0) { + return { steps, activeIndex: currentIndex }; + } + const lastCompletedIndex = steps.reduce((lastIndex, step, index) => (step.status === "completed" ? index : lastIndex), -1); + return { steps, activeIndex: Math.max(0, lastCompletedIndex) }; + } + + function progressVisualStatus(step, activeIndex) { + if (step.status === "failed" || step.status === "error") { + return "failed"; + } + if (step.status === "completed" || step.index < activeIndex) { + return "done"; + } + if (step.index === activeIndex) { + return "active"; + } + return "pending"; + } + + function stepTipText(step, activeIndex) { + const visualStatus = progressVisualStatus(step, activeIndex); + if (visualStatus === "done") { + return `${step.label}:已完成`; + } + if (visualStatus === "active") { + return `${step.label}:当前步骤`; + } + if (visualStatus === "failed") { + return `${step.label}:处理异常`; + } + return `${step.label}:等待前序步骤`; + } + + function applyProgressRoot(progress, variant) { + const className = variant === "a" ? "composer-progress chevrons" : `composer-progress progress-shell progress-variant-${variant}`; + progress.className = className; + if (typeof progress.setAttribute === "function") { + progress.setAttribute("class", className); + } + progress.setAttribute("data-progress-variant", variant); + } + + function debugDrawerIsOpen() { + const drawer = byId("debug-drawer"); + return Boolean(drawer && drawer.open); + } + + function hideComposerProgress(progress, ui) { + clearElement(progress); + cancelProgressAnimation(); + progress.hidden = true; + progress.className = "composer-progress"; + if (typeof progress.setAttribute === "function") { + progress.setAttribute("class", "composer-progress"); + progress.setAttribute("data-progress-variant", ui.variant); + progress.setAttribute("data-progress-mode", "pipeline"); + progress.setAttribute("data-progress-visible", "false"); + } + } + + function renderChevronProgress(progress, models, params) { + applyProgressRoot(progress, "a"); + progress.setAttribute("style", `--progress-a-sweep-ms: ${params.sweepMs}ms;`); + models.steps.forEach((step) => { + const visualStatus = progressVisualStatus(step, models.activeIndex); + const item = createElement("div", `step ${visualStatus === "done" ? "done" : visualStatus === "active" ? "active" : ""}`); + if (item) { + item.setAttribute("data-step-index", String(step.index)); + item.setAttribute("data-progress-step", step.id); + item.setAttribute("data-status", step.status); + item.setAttribute("title", stepTipText(step, models.activeIndex)); + } + appendChild(item, document.createTextNode ? document.createTextNode(step.label) : createElement("span", "", step.label)); + appendChild(item, createElement("span", "tip", stepTipText(step, models.activeIndex))); + appendChild(progress, item); + }); + } + + function pathLine(startX, endX, y = 22) { + return startX === endX ? "" : `M ${startX} ${y} L ${endX} ${y}`; + } + + function renderSignalProgress(progress, models, params) { + applyProgressRoot(progress, "b"); + const activeIndex = models.activeIndex; + const stepPercents = [6, 28, 50, 72, 94]; + const stepXs = [20, 96, 172, 248, 324]; + const railStartX = stepXs[0]; + const railEndX = stepXs[stepXs.length - 1]; + const previousX = activeIndex > 0 ? stepXs[activeIndex - 1] : 0; + const currentX = stepXs[activeIndex]; + const nextX = activeIndex < stepXs.length - 1 ? stepXs[activeIndex + 1] : 344; + const shell = createElement("div", "signal-circuit"); + if (shell) { + shell.setAttribute("data-active-index", String(activeIndex)); + shell.setAttribute("style", `--absorb-duration: ${params.pauseTime}ms;`); + } + const svg = createElement("svg", "signal-svg"); + if (svg) { + svg.setAttribute("viewBox", "0 0 344 44"); + svg.setAttribute("preserveAspectRatio", "none"); + svg.setAttribute("aria-hidden", "true"); + [ + ["signal-rail", pathLine(railStartX, railEndX)], + ["signal-done", activeIndex > 0 ? pathLine(railStartX, stepXs[activeIndex - 1]) : ""], + ["signal-active-base signal-active-in", pathLine(previousX, currentX)], + ["signal-active-base signal-active-out", pathLine(currentX, nextX)], + ["signal-moving-wave", ""], + ].forEach(([className, pathValue]) => { + const path = createElement("path", className); + if (path) { + path.setAttribute("d", pathValue); + } + appendChild(svg, path); + }); + } + appendChild(shell, svg); + const halo = createElement("span", "signal-absorb-halo"); + if (halo) { + halo.setAttribute("aria-hidden", "true"); + halo.setAttribute("style", `left:${stepPercents[activeIndex]}%`); + } + appendChild(shell, halo); + models.steps.forEach((step) => { + const visualStatus = progressVisualStatus(step, activeIndex); + const nodeClass = [ + "signal-node", + visualStatus === "active" ? "active" : "", + visualStatus === "pending" ? "pending" : "", + step.index === activeIndex + 1 ? "next" : "", + ].filter(Boolean).join(" "); + const node = createElement("span", nodeClass); + if (node) { + node.setAttribute("data-step-index", String(step.index)); + node.setAttribute("data-progress-step", step.id); + node.setAttribute("data-status", step.status); + node.setAttribute("style", `left: ${stepPercents[step.index]}%`); + node.setAttribute("title", stepTipText(step, activeIndex)); + } + appendChild(node, createElement("span", "signal-node-charge")); + appendChild(node, createElement("span", "signal-node-core")); + appendChild(shell, node); + }); + const labels = createElement("div", "signal-labels"); + models.steps.forEach((step) => { + const label = createElement("span", progressVisualStatus(step, activeIndex) === "active" ? "active" : "", step.label); + if (label) { + label.setAttribute("data-step-index", String(step.index)); + label.setAttribute("style", `left: ${stepPercents[step.index]}%`); + } + appendChild(labels, label); + }); + appendChild(shell, labels); + appendChild(progress, shell); + } + + function renderFusionProgress(progress, models, params) { + applyProgressRoot(progress, "d"); + const activeIndex = models.activeIndex; + const shell = createElement("div", "fusion-label"); + if (shell) { + shell.setAttribute("data-active-index", String(activeIndex)); + shell.setAttribute("style", `--fusion-sweep-duration: ${params.t1}ms;`); + } + const steps = createElement("div", "fusion-steps"); + models.steps.forEach((step) => { + const visualStatus = progressVisualStatus(step, activeIndex); + const item = createElement("div", `fusion-step ${visualStatus === "done" ? "done" : visualStatus === "active" ? "active" : ""}`); + if (item) { + item.setAttribute("data-step-index", String(step.index)); + item.setAttribute("data-progress-step", step.id); + item.setAttribute("data-status", step.status); + item.setAttribute("title", stepTipText(step, activeIndex)); + } + appendChild(item, createElement("span", "label", step.label)); + appendChild(item, createElement("span", "tip", stepTipText(step, activeIndex))); + appendChild(steps, item); + }); + appendChild(shell, steps); + appendChild(progress, shell); + } + + function renderNormalHandoffMessage(stepList, state) { + if (!stepList || !state || !state.normalHandoffReady) { + return false; + } + const message = createElement("article", "normal-handoff-message"); + if (message) { + message.setAttribute("data-normal-handoff-message", "true"); + message.setAttribute("role", "status"); + } + appendChild(message, createElement("p", "", NORMAL_HANDOFF_TEXT)); + appendChild(stepList, createChatMessage("system", message)); + return true; + } + + function createChatMessage(role, content) { + const messageRole = role === "user" ? "user" : "system"; + const message = createElement("div", `chat-message ${messageRole}`); + if (message) { + message.setAttribute("data-chat-message", messageRole); + } + const avatar = createElement("span", `chat-avatar ${messageRole}`, messageRole === "user" ? "U" : "AI"); + if (avatar) { + avatar.setAttribute("data-chat-avatar", messageRole); + } + const bubble = createElement("div", "chat-bubble"); + appendChild(bubble, content); + appendChild(message, avatar); + appendChild(message, bubble); + return message; + } + + function createUserMessage(text) { + return createChatMessage("user", createElement("p", "user-message-text", text)); + } + + function normalProcessIsExpanded(turn) { + const state = ensureState(); + if (turn && turn.status === "working") { + return true; + } + return Boolean(state.expandedNormalProcesses && turn && state.expandedNormalProcesses[turn.id]); + } + + function normalProcessEventLabel(kind) { + return { + thinking: "思考", + tool: "工具", + permission: "权限", + error: "异常", + }[kind] || "过程"; + } + + function renderNormalProcess(turn) { + const events = Array.isArray(turn && turn.events) ? turn.events : []; + if (!events.length) { + return null; + } + const details = createElement("details", "normal-process"); + if (details) { + details.setAttribute("data-normal-process", turn.id); + details.open = normalProcessIsExpanded(turn); + details.addEventListener("toggle", () => { + if (turn.status === "working") { + return; + } + const state = ensureState(); + state.expandedNormalProcesses = state.expandedNormalProcesses || {}; + state.expandedNormalProcesses[turn.id] = Boolean(details.open); + }); + } + const summary = createElement("summary", "normal-process-summary"); + appendChild(summary, createElement("span", "normal-process-title", "思考过程")); + appendChild(summary, createElement("span", "normal-process-count", `${events.length} 条`)); + appendChild(details, summary); + const list = createElement("ul", "normal-process-events"); + events.forEach((event) => { + const kind = event && event.kind ? String(event.kind) : "event"; + const item = createElement("li", `normal-process-event ${kind}`); + if (item) { + item.setAttribute("data-normal-process-event", kind); + } + appendChild(item, createElement("span", "normal-process-event-label", event.label || normalProcessEventLabel(kind))); + appendChild(item, createElement("p", "", event.text || "")); + appendChild(list, item); + }); + appendChild(details, list); + return details; + } + + function renderNormalTurn(stepList, turn, renderedTurnIds) { + if (!stepList || !turn || (renderedTurnIds && renderedTurnIds.has(turn.id))) { + return; + } + const content = createElement("article", `normal-turn ${turn.status || "completed"}`); + if (content) { + content.setAttribute("data-normal-turn", turn.id); + } + appendChild(content, renderNormalProcess(turn)); + const answer = createElement( + "p", + "normal-answer", + turn.answer || (turn.status === "working" ? "正在整理回复..." : "") + ); + if (answer) { + answer.setAttribute("data-normal-answer", turn.id); + } + appendChild(content, answer); + appendChild(stepList, createChatMessage("system", content)); + if (renderedTurnIds) { + renderedTurnIds.add(turn.id); + } + } + + function userMessageKey(item, index) { + return item && item.id ? String(item.id) : `user-message-${index}`; + } + + function userMessagePlacement(item) { + const placement = item && item.placement && typeof item.placement === "object" ? item.placement : {}; + if (placement.position === "after_normal_handoff" || placement.after === "normal_handoff") { + return { position: "after_normal_handoff" }; + } + if (placement.afterStepId || item.afterStepId) { + return { position: "after_step", afterStepId: placement.afterStepId || item.afterStepId }; + } + return { position: "start" }; + } + + function messageBelongsToPosition(item, position, value) { + const placement = userMessagePlacement(item); + if (position === "start") { + return placement.position === "start"; + } + if (position === "after_normal_handoff") { + return placement.position === "after_normal_handoff"; + } + if (position === "after_step") { + return placement.position === "after_step" && placement.afterStepId === value; + } + return false; + } + + function renderUserMessages(stepList, state, position, value, renderedKeys) { + const messages = Array.isArray(state && state.userMessages) ? state.userMessages : []; + messages.forEach((item, index) => { + const key = userMessageKey(item, index); + if (renderedKeys && renderedKeys.has(key)) { + return; + } + if (!messageBelongsToPosition(item, position, value)) { + return; + } + const text = item && item.text ? String(item.text) : ""; + if (!text) { + return; + } + appendChild(stepList, createUserMessage(text)); + if (renderedKeys) { + renderedKeys.add(key); + } + }); + } + + function renderNormalHandoffConversation(stepList, state, renderedKeys) { + const messages = Array.isArray(state && state.userMessages) ? state.userMessages : []; + const turns = Array.isArray(state && state.normalTurns) ? state.normalTurns : []; + const renderedTurnIds = new Set(); + messages.forEach((item, index) => { + const key = userMessageKey(item, index); + if (renderedKeys && renderedKeys.has(key)) { + return; + } + if (!messageBelongsToPosition(item, "after_normal_handoff", "")) { + return; + } + const text = item && item.text ? String(item.text) : ""; + if (!text) { + return; + } + appendChild(stepList, createUserMessage(text)); + if (renderedKeys) { + renderedKeys.add(key); + } + turns + .filter((turn) => turn && turn.afterUserMessageId === key) + .forEach((turn) => renderNormalTurn(stepList, turn, renderedTurnIds)); + }); + turns + .filter((turn) => turn && !renderedTurnIds.has(turn.id)) + .forEach((turn) => renderNormalTurn(stepList, turn, renderedTurnIds)); + } + + function userMessagePlacementForState(state) { + if (state && state.normalHandoffReady) { + return { position: "after_normal_handoff" }; + } + if (state && pendingInputIsCandidateSelection(state.pendingInput)) { + return { position: "after_step", afterStepId: "confirm_and_select" }; + } + const steps = (state && state.steps) || {}; + const activeStepId = STEP_ORDER.find((stepId) => { + const status = stepStatusClass(normalizeStatus(steps[stepId] && steps[stepId].status)); + return status === "working" || status === "waiting_input"; + }); + if (state && state.pendingInput && activeStepId) { + return { position: "after_step", afterStepId: activeStepId }; + } + return { position: "start" }; + } + + function renderSteps() { + const state = ensureState(); + const stepList = byId("step-list"); + if (!stepList || !canCreateElements()) { + return; + } + clearElement(stepList); + const renderedUserMessages = new Set(); + renderUserMessages(stepList, state, "start", "", renderedUserMessages); + STEP_ORDER.forEach((stepId, index) => { + const step = state.steps && state.steps[stepId] ? state.steps[stepId] : createSteps()[stepId]; + if (!stepIsVisible(step)) { + return; + } + const status = stepStatusClass(normalizeStatus(step.status) || "pending"); + const isCurrent = stepIsOpen(status); + const isExpanded = stepDetailsExpanded(stepId, status); + const card = createElement("article", `step-card ${status}${isCurrent ? " current" : ""}`); + const marker = createElement("span", "step-index"); + const body = createElement("div", "step-card-body"); + const title = createElement("h2", "", step.label || STEP_LABELS[stepId] || stepId); + if (card) { + card.setAttribute("data-step-id", stepId); + card.setAttribute("data-status", status); + if (isCurrent) { + card.setAttribute("aria-current", "step"); + } + } + const iconText = stepStateIcon(status); + if (iconText) { + const icon = createElement("span", `step-state-icon ${status}`, iconText); + if (icon) { + icon.setAttribute("data-step-state-icon", status); + } + appendChild(marker, icon); + } + if (stepCanToggle(status)) { + const toggle = createElement("button", "step-toggle"); + if (toggle) { + toggle.setAttribute("type", "button"); + toggle.setAttribute("data-step-toggle", stepId); + toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false"); + toggle.addEventListener("click", () => toggleStepDetails(stepId)); + } + appendChild(toggle, title); + appendChild(toggle, createElement("span", `step-toggle-icon${isExpanded ? " expanded" : ""}`)); + appendChild(body, toggle); + } else { + appendChild(body, title); + } + appendChild(card, marker); + appendChild(card, body); + renderStepDetails(card, step, status, isExpanded); + appendChild(stepList, createChatMessage("system", card)); + renderUserMessages(stepList, state, "after_step", stepId, renderedUserMessages); + }); + if (renderNormalHandoffMessage(stepList, state)) { + renderNormalHandoffConversation(stepList, state, renderedUserMessages); + } + renderUserMessages(stepList, state, "after_step", "", renderedUserMessages); + if (stepList.children && stepList.children.length > 0) { + scrollElementToBottom(stepList); + } + } + + function renderComposerProgress() { + const state = ensureState(); + const progress = byId("composer-progress"); + if (!progress || !canCreateElements()) { + return; + } + clearElement(progress); + const ui = mergeProgressUi(state.progressUi); + state.progressUi = ui; + const isDebugPreview = debugDrawerIsOpen(); + if (!isDebugPreview && !state.pipelineStarted) { + hideComposerProgress(progress, ui); + return; + } + progress.hidden = false; + progress.setAttribute("data-progress-mode", isDebugPreview ? "debug" : "pipeline"); + progress.setAttribute("data-progress-visible", "true"); + const models = stepModelsForProgress(state, ui, { useConfiguredActiveStep: isDebugPreview }); + if (ui.variant === "a") { + renderChevronProgress(progress, models, ui.a); + } else if (ui.variant === "d") { + renderFusionProgress(progress, models, ui.d); + } else { + renderSignalProgress(progress, models, ui.b); + } + startProgressAnimation(); + } + + function smoothstep(edge0, edge1, value) { + if (edge0 === edge1) { + return value < edge0 ? 0 : 1; + } + const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); + } + + function cancelProgressAnimation() { + controller.progressAnimationToken += 1; + if (controller.progressAnimationFrame !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(controller.progressAnimationFrame); + } + if (typeof window !== "undefined" && window.clearTimeout) { + window.clearTimeout(controller.progressRunTimer); + window.clearTimeout(controller.progressWaitTimer); + } + controller.progressAnimationFrame = null; + controller.progressRunTimer = 0; + controller.progressWaitTimer = 0; + } + + function startFusionProgressAnimation(progress, ui) { + const label = progress.querySelector ? progress.querySelector(".fusion-label") : null; + if (!label || typeof requestAnimationFrame !== "function") { + return; + } + const activeIndex = Number(label.getAttribute("data-active-index")); + const timing = ui.d; + + const percent = (value) => `${Math.max(0, Math.min(100, value)).toFixed(2)}%`; + const syncBorder = () => { + const activeStep = label.querySelector(`.fusion-step[data-step-index="${activeIndex}"]`); + if (!activeStep || !label.getBoundingClientRect || !activeStep.getBoundingClientRect) { + return; + } + const labelRect = label.getBoundingClientRect(); + const activeRect = activeStep.getBoundingClientRect(); + if (!labelRect.width) { + return; + } + const activeStart = ((activeRect.left - labelRect.left) / labelRect.width) * 100; + const activeEnd = ((activeRect.right - labelRect.left) / labelRect.width) * 100; + const blueStart = activeIndex === 0 ? 0 : activeStart; + const greenEnd = activeIndex === 0 ? 0 : activeStart; + const blueEnd = activeIndex === STEP_ORDER.length - 1 ? 100 : activeEnd; + label.style.setProperty("--fusion-green-end", percent(greenEnd)); + label.style.setProperty("--fusion-blue-start", percent(blueStart)); + label.style.setProperty("--fusion-blue-end", percent(blueEnd)); + label.style.setProperty("--fusion-sweep-duration", `${timing.t1}ms`); + }; + + const restartSweeps = () => { + window.clearTimeout(controller.progressRunTimer); + window.clearTimeout(controller.progressWaitTimer); + label.classList.remove("sweep-wait"); + label.classList.add("sweep-reset"); + void label.offsetWidth; + label.classList.remove("sweep-reset"); + controller.progressRunTimer = window.setTimeout(() => { + label.classList.add("sweep-wait"); + controller.progressWaitTimer = window.setTimeout(restartSweeps, timing.t2); + }, timing.t1); + }; + + requestAnimationFrame(() => { + syncBorder(); + restartSweeps(); + }); + } + + function startSignalProgressAnimation(progress, ui) { + if (typeof requestAnimationFrame !== "function") { + return; + } + const wave = progress.querySelector ? progress.querySelector(".signal-moving-wave") : null; + const demo = progress.querySelector ? progress.querySelector(".signal-circuit") : null; + if (!wave || !demo) { + return; + } + + const params = ui.b; + const stepXs = [20, 96, 172, 248, 324]; + const baseY = 22; + const viewMinX = 0; + const viewMaxX = 344; + const virtualPadding = 66; + const virtualLeftX = stepXs[0] - virtualPadding; + const virtualRightX = stepXs[stepXs.length - 1] + virtualPadding; + const nodeClearance = 10; + const outboundTailClearance = 6; + let activeIndex = Number(demo.getAttribute("data-active-index")); + let phase = "inbound"; + let elapsed = 0; + let pauseLeft = 0; + let last = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + let cycleSalt = 0; + let absorbTimer = 0; + const token = controller.progressAnimationToken; + + const clampToView = (x) => Math.max(viewMinX, Math.min(viewMaxX, x)); + const inboundSegment = () => { + const currentX = stepXs[activeIndex]; + return { + from: activeIndex === 0 ? virtualLeftX : stepXs[activeIndex - 1] + nodeClearance, + to: currentX - nodeClearance, + color: "#1677ff", + nextPhase: "pause-current", + }; + }; + const outboundSegment = () => { + const currentX = stepXs[activeIndex]; + return { + from: currentX + nodeClearance, + to: activeIndex === stepXs.length - 1 ? virtualRightX : stepXs[activeIndex + 1] - outboundTailClearance, + color: "#8f9bae", + nextPhase: "pause-next", + }; + }; + const currentSegment = () => (phase === "outbound" || phase === "pause-next" ? outboundSegment() : inboundSegment()); + const segmentMotion = (timeMs) => { + const x = Math.max(0.04, Math.min(0.48, params.xPercent / 100)); + const y = Math.max(0, Math.min(1, params.yPercent / 100)); + const t1 = Math.max(40, params.t1); + const t2 = Math.max(80, params.t2); + if (timeMs < t1) { + const u = Math.max(0, Math.min(1, timeMs / t1)); + return { anchor: "right", progress: x * u, amplitudeScale: y * smoothstep(0, 1, u), done: false }; + } + if (timeMs < t1 + t2) { + const u = Math.max(0, Math.min(1, (timeMs - t1) / t2)); + return { anchor: "right", progress: x + (1 - x) * u, amplitudeScale: y + (1 - y) * Math.sin(Math.PI * u), done: false }; + } + if (timeMs < t1 * 2 + t2) { + const u = Math.max(0, Math.min(1, (timeMs - t1 - t2) / t1)); + return { anchor: "left", progress: 1 - x + x * u, amplitudeScale: y * (1 - smoothstep(0, 1, u)), done: false }; + } + return { anchor: "left", progress: 1, amplitudeScale: 0, done: true }; + }; + const pulseShape = (t) => { + const micro = 0.1 * Math.sin((t * 2.6 + cycleSalt) * Math.PI); + const lift = Math.sin(Math.PI * smoothstep(0.16, 0.38, t)); + const drop = Math.sin(Math.PI * smoothstep(0.37, 0.62, t)); + const settle = 0.2 * Math.sin((t - 0.62) * Math.PI * 4.5 + cycleSalt * 0.4); + return micro + lift - drop * 0.86 + settle * smoothstep(0.58, 0.96, t); + }; + const movingWavePath = () => { + if (phase === "pause-current" || phase === "pause-next") { + return ""; + } + const segment = currentSegment(); + const segmentLength = segment.to - segment.from; + const xRatio = Math.max(0.04, Math.min(0.48, params.xPercent / 100)); + const waveLength = segmentLength * xRatio; + const motion = segmentMotion(elapsed); + const amplitude = params.maxAmplitude * motion.amplitudeScale; + if (amplitude < 0.2) { + return ""; + } + const right = + motion.anchor === "left" + ? segment.from + motion.progress * segmentLength + waveLength + : segment.from + motion.progress * segmentLength; + const left = motion.anchor === "left" ? segment.from + motion.progress * segmentLength : right - waveLength; + const start = Math.max(segment.from, left); + const end = Math.min(segment.to, right); + if (end <= segment.from || start >= segment.to || end - start < 1) { + return ""; + } + const points = []; + const samples = 54; + for (let i = 0; i <= samples; i += 1) { + const t = i / samples; + const x = start + t * (end - start); + const packetT = left < segment.from ? t : (x - left) / waveLength; + const envelope = smoothstep(0, 0.16, packetT) * (1 - smoothstep(0.84, 1, packetT)); + const y = baseY - pulseShape(packetT) * amplitude * envelope; + points.push(`${i === 0 ? "M" : "L"} ${clampToView(x).toFixed(2)} ${y.toFixed(2)}`); + } + return points.join(" "); + }; + const render = () => { + const segment = currentSegment(); + wave.style.stroke = segment.color; + wave.setAttribute("d", movingWavePath()); + }; + const triggerAbsorbHalo = () => { + demo.classList.remove("absorbing"); + window.clearTimeout(absorbTimer); + void demo.offsetWidth; + demo.classList.add("absorbing"); + absorbTimer = window.setTimeout(() => { + demo.classList.remove("absorbing"); + }, params.pauseTime); + }; + const tick = (now) => { + if (token !== controller.progressAnimationToken) { + return; + } + const dt = Math.min(48, now - last) / 1000; + last = now; + if (phase === "pause-current" || phase === "pause-next") { + pauseLeft -= dt * 1000; + if (pauseLeft <= 0) { + if (phase === "pause-current") { + demo.classList.remove("absorbing"); + window.clearTimeout(absorbTimer); + } + phase = phase === "pause-current" ? "outbound" : "inbound"; + elapsed = 0; + cycleSalt = (cycleSalt + 0.73) % (Math.PI * 2); + } + render(); + controller.progressAnimationFrame = requestAnimationFrame(tick); + return; + } + const segment = currentSegment(); + elapsed += dt * 1000; + if (segmentMotion(elapsed).done) { + pauseLeft = params.pauseTime; + phase = segment.nextPhase; + if (phase === "pause-current") { + triggerAbsorbHalo(); + } + elapsed = params.t1 * 2 + params.t2; + } + render(); + controller.progressAnimationFrame = requestAnimationFrame(tick); + }; + + requestAnimationFrame((now) => { + last = now; + render(); + tick(now); + }); + } + + function startProgressAnimation() { + cancelProgressAnimation(); + const progress = byId("composer-progress"); + if (!progress || progress.hidden) { + return; + } + const ui = mergeProgressUi(ensureState().progressUi); + if (progress.getAttribute("data-progress-variant") === "b") { + startSignalProgressAnimation(progress, ui); + } + if (progress.getAttribute("data-progress-variant") === "d") { + startFusionProgressAnimation(progress, ui); + } + } + + function costItemLabel(item) { + if (!item || typeof item !== "object") { + return ""; + } + const name = item.name || item.resource || item.type || item.product || "费用项"; + const spec = item.spec || item.instanceType || item.instance_type || item.description || ""; + const cost = item.monthly_cost ?? item.monthlyCost ?? item.totalMonthlyCost ?? item.cost ?? ""; + return [name, spec, cost].filter((value) => value !== "" && value !== null && value !== undefined).join(" · "); + } + + function presentValue(value, fallback) { + if (value === 0 || value) { + return String(value); + } + return fallback; + } + + function candidateSubStepOf(event) { + const data = eventData(event); + return ( + (event && event.candidateStep && typeof event.candidateStep === "object" ? event.candidateStep : null) || + (event && event.candidate_step && typeof event.candidate_step === "object" ? event.candidate_step : null) || + (data.candidateStep && typeof data.candidateStep === "object" ? data.candidateStep : null) || + (data.candidate_step && typeof data.candidate_step === "object" ? data.candidate_step : null) || + {} + ); + } + + function candidateSubStepLabel(event) { + const subStep = candidateSubStepOf(event); + const rawLabel = subStep.label || subStep.name || subStep.title || subStep.id || ""; + const normalizedLabel = String(rawLabel || "").trim(); + if (CANDIDATE_SUBSTEP_LABELS[normalizedLabel]) { + return CANDIDATE_SUBSTEP_LABELS[normalizedLabel]; + } + return normalizedLabel || "方案思考"; + } + + function candidateSubEventKind(event) { + const eventType = eventTypeOf(event || {}); + return String(eventType || "").startsWith("candidate_step") ? eventType : stepEventKind(event); + } + + function isCandidateLifecycleEvent(event) { + const eventType = eventTypeOf(event || {}); + return eventType === "candidate_started" || eventType === "candidate_completed" || eventType === "candidate_failed"; + } + + function candidateRenderableSubEvents(events) { + return (Array.isArray(events) ? events : []).filter((event) => !isCandidateLifecycleEvent(event)); + } + + function candidateSubEventLabel(kind) { + const labels = { + candidate_step_completed: "子步骤完成", + candidate_step_failed: "子步骤失败", + candidate_step_started: "子步骤开始", + candidate_started: "方案开始", + candidate_completed: "方案完成", + candidate_failed: "方案异常", + text_delta: "思考片段", + tool_result: "工具结果", + tool_use: "工具调用", + }; + return labels[kind] || stepEventLabel(kind); + } + + function candidateSubPipelineState(candidate) { + const events = Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : []; + const latest = events[events.length - 1]; + const eventType = eventTypeOf(latest || {}); + const status = normalizeStatus((latest && latest.status) || candidateSubStepOf(latest).status || ""); + if (eventType === "candidate_completed") { + return "completed"; + } + if (eventType === "candidate_failed") { + return "failed"; + } + if (eventType === "candidate_step_failed" || status === "failed" || status === "error") { + return "failed"; + } + return "working"; + } + + function candidateSubPipelineStatus(candidate) { + const state = candidateSubPipelineState(candidate); + if (state === "completed") { + return "思考完成"; + } + if (state === "failed") { + return "思考异常"; + } + return "思考中"; + } + + function candidatePlanStatus(candidate) { + const events = Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : []; + if (events.length === 0) { + return null; + } + const state = candidateSubPipelineState(candidate); + if (state === "completed") { + return { state: "completed", label: "已完成" }; + } + if (state === "failed") { + return { state: "failed", label: "异常" }; + } + return { state: "working", label: "生成中" }; + } + + function candidateSubStepId(event, fallbackIndex) { + const subStep = candidateSubStepOf(event); + return String(subStep.id || subStep.stepId || subStep.name || subStep.label || `step-${fallbackIndex}`); + } + + function candidateSubStepStatus(events, forceComplete = false) { + const latest = events[events.length - 1]; + const eventType = eventTypeOf(latest || {}); + const status = normalizeStatus((latest && latest.status) || candidateSubStepOf(latest).status || ""); + if (eventType === "candidate_step_completed" || status === "completed" || forceComplete) { + return "completed"; + } + if (eventType === "candidate_step_failed" || status === "failed" || status === "error") { + return "failed"; + } + return "working"; + } + + function groupCandidateSubEvents(events, options = {}) { + const forceComplete = Boolean(options.forceComplete); + const groups = []; + events.forEach((event, index) => { + const id = candidateSubStepId(event, index); + let group = groups.find((item) => item.id === id); + if (!group) { + group = { + id, + label: candidateSubStepLabel(event), + events: [], + }; + groups.push(group); + } + group.events.push(event); + group.label = group.label || candidateSubStepLabel(event); + group.status = candidateSubStepStatus(group.events, forceComplete); + }); + return groups; + } + + function candidateEvaluationIsComplete() { + const state = ensureState(); + const steps = state.steps || {}; + const evaluationStatus = stepStatusClass(normalizeStatus(steps.evaluate_candidates && steps.evaluate_candidates.status)); + const selectionStatus = stepStatusClass(normalizeStatus(steps.confirm_and_select && steps.confirm_and_select.status)); + const deploymentStatus = stepStatusClass(normalizeStatus(steps.deploying && steps.deploying.status)); + return ( + evaluationStatus === "completed" || + ["working", "waiting_input", "completed"].includes(selectionStatus) || + ["working", "waiting_input", "completed"].includes(deploymentStatus) + ); + } + + function candidateEvaluationIsWorking() { + const state = ensureState(); + const steps = state.steps || {}; + return stepStatusClass(normalizeStatus(steps.evaluate_candidates && steps.evaluate_candidates.status)) === "working"; + } + + function scrollElementToBottom(element) { + if (!element || typeof element.scrollTop === "undefined") { + return; + } + const scroll = () => { + element.scrollTop = element.scrollHeight || 0; + }; + scroll(); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(scroll); + } + } + + function renderCandidateSubstepGroup(group) { + const substep = createElement("details", "candidate-substep"); + if (substep) { + substep.setAttribute("data-candidate-substep", group.id); + substep.open = group.status !== "completed"; + } + const substepHead = createElement("summary", "candidate-substep-head"); + appendChild(substepHead, createElement("strong", "", group.label)); + appendChild(substepHead, createElement("span", "", group.status === "completed" ? "完成" : group.status === "failed" ? "异常" : "进行中")); + appendChild(substep, substepHead); + const list = createElement("ul", "candidate-subpipeline-events"); + group.events.forEach((event) => { + const kind = candidateSubEventKind(event); + const item = createElement("li", `candidate-subpipeline-event ${kind}`); + if (item) { + item.setAttribute("data-candidate-subpipeline-event", kind); + } + appendChild(item, createElement("span", "candidate-subpipeline-label", candidateSubEventLabel(kind))); + appendChild(item, createElement("p", "", eventTitle(event))); + appendChild(list, item); + }); + appendChild(substep, list); + return substep; + } + + function renderCandidateSubPipeline(card, candidate, candidateIndex) { + const events = compactDisplayEvents(Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : []); + const renderableEvents = candidateRenderableSubEvents(events); + if (renderableEvents.length === 0) { + return; + } + const state = ensureState(); + const pipelineKey = String(candidateIndex); + const pipelineState = candidateSubPipelineState(candidate); + const shouldAutoOpen = candidateEvaluationIsWorking() && pipelineState === "working"; + const section = createElement("details", "candidate-subpipeline"); + if (section) { + section.setAttribute("data-candidate-subpipeline", pipelineKey); + section.open = shouldAutoOpen || Boolean(state.expandedCandidateSubpipelines && state.expandedCandidateSubpipelines[pipelineKey]); + section.addEventListener("click", (event) => { + if (event && typeof event.stopPropagation === "function") { + event.stopPropagation(); + } + }); + section.addEventListener("toggle", () => { + const nextState = ensureState(); + nextState.expandedCandidateSubpipelines = nextState.expandedCandidateSubpipelines || {}; + nextState.expandedCandidateSubpipelines[pipelineKey] = Boolean(section.open); + }); + section.addEventListener("keydown", (event) => { + if (event && (event.key === "Enter" || event.key === " ")) { + event.stopPropagation(); + } + }); + } + const head = createElement("summary", "candidate-subpipeline-head"); + if (head) { + head.setAttribute("data-candidate-subpipeline-toggle", String(candidateIndex)); + } + appendChild(head, createElement("strong", "", "思考过程")); + appendChild(head, createElement("span", "candidate-subpipeline-arrow")); + appendChild(section, head); + const body = createElement("div", "candidate-subpipeline-body"); + if (body) { + body.setAttribute("data-candidate-subpipeline-body", pipelineKey); + } + const substeps = createElement("div", "candidate-substeps"); + groupCandidateSubEvents(renderableEvents, { forceComplete: pipelineState === "completed" || candidateEvaluationIsComplete() }).forEach((group) => { + appendChild(substeps, renderCandidateSubstepGroup(group)); + }); + appendChild(body, substeps); + appendChild(section, body); + appendChild(card, section); + if (section && section.open) { + scrollElementToBottom(body); + } + } + + function candidateIndexOf(candidate, fallbackIndex) { + const rawIndex = candidate && candidate.candidateIndex !== null && candidate.candidateIndex !== undefined + ? candidate.candidateIndex + : fallbackIndex; + const numericIndex = Number(rawIndex); + return Number.isFinite(numericIndex) ? numericIndex : fallbackIndex; + } + + function renderPlans() { + const state = ensureState(); + const plansGrid = byId("plans-grid"); + if (!plansGrid || !canCreateElements()) { + return; + } + clearElement(plansGrid); + (Array.isArray(state.candidates) ? state.candidates : []).forEach((candidate, index) => { + const candidateIndex = candidateIndexOf(candidate, index); + const isSelected = state.selectedCandidateIndex === candidateIndex; + const isRecommended = isSelected || (state.selectedCandidateIndex === null && index === 0); + const cardClasses = ["plan-card", isSelected ? "selected" : "", isRecommended ? "recommended" : ""] + .filter(Boolean) + .join(" "); + const card = createElement("article", cardClasses); + const header = createElement("div", "plan-card-header"); + const tag = createElement("span", `tag${isRecommended ? "" : " muted"}`, isSelected ? "已选" : index === 0 ? "推荐" : "备选"); + const score = createElement("span", "score", `方案 ${candidateIndex}`); + const planStatus = candidatePlanStatus(candidate); + const title = createElement("h2", "", candidate.name || `方案 ${candidateIndex}`); + const summary = createElement("p", "", candidate.summary || "等待方案摘要"); + const price = createElement("div", "price"); + const meta = createElement("dl", "plan-meta"); + const costItems = Array.isArray(candidate.costItems) ? candidate.costItems : []; + const templateHoverText = isTemplateLikeText(candidate.template) ? String(candidate.template) : ""; + + if (card) { + card.setAttribute("role", "button"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-pressed", isSelected ? "true" : "false"); + card.setAttribute("data-candidate-index", String(candidateIndex)); + card.addEventListener("click", () => { + controller.state = selectCandidate(ensureState(), candidateIndex); + syncComposerWithSelectedCandidate(controller.state); + renderAll(); + }); + card.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + controller.state = selectCandidate(ensureState(), candidateIndex); + syncComposerWithSelectedCandidate(controller.state); + renderAll(); + } + }); + } + + appendChild(header, tag); + const headerMeta = createElement("div", "plan-card-header-meta"); + appendChild(headerMeta, score); + if (planStatus) { + const status = createElement("span", `plan-status ${planStatus.state}`, planStatus.label); + if (status) { + status.setAttribute("data-candidate-status", planStatus.state); + } + appendChild(headerMeta, status); + } + appendChild(header, headerMeta); + appendChild(card, header); + appendChild(card, title); + appendChild(card, summary); + appendChild(price, createElement("span", "price-label", "预估价格")); + appendChild(price, createElement("strong", "", presentValue(candidate.totalMonthlyCost, "价格待确认"))); + appendChild(card, price); + + costItems.slice(0, 4).forEach((item) => { + const row = createElement("div"); + const term = createElement("dt", "", item && (item.name || item.resource || item.product) ? item.name || item.resource || item.product : "资源"); + const detail = createElement("dd", "", costItemLabel(item)); + appendChild(row, term); + appendChild(row, detail); + appendChild(meta, row); + }); + appendChild(card, meta); + renderCandidateSubPipeline(card, candidate, candidateIndex); + attachTemplatePopover(card, templateHoverText); + appendChild(plansGrid, card); + }); + } + + function formatProgressParamValue(definition, value) { + const numericValue = Number(value); + const rendered = Number.isFinite(numericValue) && definition.step < 1 ? numericValue.toFixed(1) : String(value); + return `${rendered}${definition.unit || ""}`; + } + + function setProgressVariant(variant) { + const state = ensureState(); + const ui = mergeProgressUi(state.progressUi); + if (PROGRESS_VARIANT_ORDER.includes(variant)) { + ui.variant = variant; + } + state.progressUi = ui; + renderAll(); + } + + function setProgressParam(variant, key, value) { + const state = ensureState(); + const ui = mergeProgressUi(state.progressUi); + if (!PROGRESS_VARIANT_ORDER.includes(variant) || !Object.prototype.hasOwnProperty.call(ui[variant], key)) { + return; + } + const numericValue = Number(value); + if (Number.isFinite(numericValue)) { + ui[variant][key] = numericValue; + state.progressUi = ui; + renderAll(); + } + } + + function setProgressStep(index) { + const state = ensureState(); + const ui = mergeProgressUi(state.progressUi); + const numericIndex = Number(index); + ui.activeStepIndex = Number.isInteger(numericIndex) && numericIndex >= 0 && numericIndex < STEP_ORDER.length ? numericIndex : null; + state.progressUi = ui; + renderAll(); + } + + function renderProgressDebugPanel() { + const panel = byId("progress-debug-panel"); + if (!panel || !canCreateElements()) { + return; + } + const state = ensureState(); + const ui = mergeProgressUi(state.progressUi); + state.progressUi = ui; + clearElement(panel); + + const title = createElement("div", "progress-debug-title"); + appendChild(title, createElement("strong", "", "进度条方案")); + appendChild(title, createElement("span", "", "用于切换视觉方案与调参,不影响 pipeline 状态")); + appendChild(panel, title); + + const variants = createElement("div", "progress-variant-switch"); + PROGRESS_VARIANT_ORDER.forEach((variant) => { + const button = createElement("button", ui.variant === variant ? "selected" : "", PROGRESS_VARIANT_LABELS[variant]); + if (button) { + button.setAttribute("type", "button"); + button.setAttribute("data-progress-variant-option", variant); + button.setAttribute("aria-pressed", ui.variant === variant ? "true" : "false"); + button.addEventListener("click", () => setProgressVariant(variant)); + } + appendChild(variants, button); + }); + appendChild(panel, variants); + + const activeIndex = stepModelsForProgress(state, ui, { useConfiguredActiveStep: true }).activeIndex; + const stepControl = createElement("div", "demo-step-control progress-demo-step-control"); + const stepLabel = createElement("label"); + appendChild(stepLabel, createElement("span", "", "演示 Step")); + appendChild(stepLabel, createElement("output", "", STEP_LABELS[STEP_ORDER[activeIndex]])); + appendChild(stepControl, stepLabel); + const stepSwitch = createElement("div", "step-switch"); + if (stepSwitch) { + stepSwitch.setAttribute("aria-label", "进度条演示当前步骤"); + } + STEP_ORDER.forEach((stepId, index) => { + const button = createElement("button", index === activeIndex ? "active" : "", String(index + 1)); + if (button) { + button.setAttribute("type", "button"); + button.setAttribute("data-progress-step-option", String(index)); + button.setAttribute("aria-pressed", index === activeIndex ? "true" : "false"); + button.setAttribute("title", STEP_LABELS[stepId]); + button.addEventListener("click", () => setProgressStep(index)); + } + appendChild(stepSwitch, button); + }); + appendChild(stepControl, stepSwitch); + appendChild(panel, stepControl); + + PROGRESS_VARIANT_ORDER.forEach((variant) => { + const group = createElement("div", "progress-param-grid"); + if (group) { + group.setAttribute("data-progress-param-group", variant); + group.hidden = ui.variant !== variant; + } + PROGRESS_PARAM_DEFS[variant].forEach((definition) => { + const value = ui[variant][definition.key]; + const field = createElement("label", "progress-param"); + const head = createElement("span", "progress-param-head"); + appendChild(head, createElement("span", "", definition.label)); + appendChild(head, createElement("output", "", formatProgressParamValue(definition, value))); + const input = createElement("input"); + if (input) { + input.setAttribute("type", "range"); + input.setAttribute("min", String(definition.min)); + input.setAttribute("max", String(definition.max)); + input.setAttribute("step", String(definition.step)); + input.setAttribute("data-progress-param", definition.key); + input.setAttribute("data-progress-param-variant", variant); + input.value = String(value); + input.addEventListener("input", () => setProgressParam(variant, definition.key, input.value)); + } + appendChild(field, head); + appendChild(field, input); + appendChild(group, field); + }); + appendChild(panel, group); + }); + } + + function renderDebugSessionInfo(state) { + const container = byId("debug-session-info"); + if (!container) { + return; + } + clearElement(container); + const fields = [ + ["serverUrl", "Server URL", state.serverUrl || ""], + ["cwd", "CWD", state.cwd || ""], + ["contextId", "Context ID", state.contextId || "未获取"], + ["pipelineTaskId", "Pipeline Task", state.pipelineTaskId || "未获取"], + ["activeTaskId", "Active Task", state.activeTaskId || "未获取"], + ["lastSequence", "Last Sequence", String(state.lastSequence || 0)], + ["status", "Status", state.status || "idle"], + ["handoff", "Normal Handoff", state.normalHandoffReady ? "是" : "否"], + ["logs", "Logs", "默认 ~/.iac-code/logs,或 IAC_CODE_CONFIG_DIR/logs"], + ]; + fields.forEach(([key, label, value]) => { + const row = createElement("div", "debug-session-field"); + if (row) { + row.setAttribute("data-debug-session-field", key); + } + appendChild(row, createElement("span", "", label)); + appendChild(row, createElement("code", "", value)); + appendChild(container, row); + }); + } + + function renderDebug() { + const output = byId("debug-output") || query("#debug-drawer pre"); + const state = ensureState(); + renderDebugSessionInfo(state); + if (!output) { + return; + } + output.textContent = JSON.stringify(state.diagnostics || {}, null, 2); + } + + function renderAll() { + renderStatus(); + renderSteps(); + renderComposerProgress(); + renderPlans(); + renderProgressDebugPanel(); + renderDebug(); + } + + function diagnosticBucket(kind) { + if (kind === "sse") { + return "sse"; + } + if (kind === "snapshot" || kind === "state") { + return "snapshots"; + } + return "requests"; + } + + function appendDiagnostic(kind, value) { + const state = ensureState(); + const diagnostics = state.diagnostics || { requests: [], sse: [], snapshots: [] }; + const bucket = diagnosticBucket(kind); + const nextValue = clonePlainData({ + at: new Date().toISOString(), + kind, + value, + }); + diagnostics[bucket] = Array.isArray(diagnostics[bucket]) ? diagnostics[bucket] : []; + diagnostics[bucket].push(nextValue); + diagnostics[bucket] = diagnostics[bucket].slice(-40); + state.diagnostics = diagnostics; + renderDebug(); + } + + function showStatus(message, kind) { + const alert = byId("status-alert"); + if (!alert) { + return; + } + if (!message) { + alert.hidden = true; + alert.textContent = ""; + alert.removeAttribute("data-kind"); + return; + } + alert.hidden = false; + alert.textContent = message; + alert.setAttribute("data-kind", kind || "info"); + } + + function ensureFetchAvailable() { + if (typeof fetch === "function") { + return true; + } + appendDiagnostic("error", { error: "fetch is not available" }); + showStatus("当前环境不支持 fetch,无法连接 A2A 服务。", "error"); + return false; + } + + function queryString(params) { + if (typeof URLSearchParams === "function") { + const search = new URLSearchParams(); + Object.keys(params).forEach((key) => { + search.set(key, params[key] === undefined || params[key] === null ? "" : String(params[key])); + }); + return search.toString(); + } + return Object.keys(params) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key] || "")}`) + .join("&"); + } + + async function readJsonResponse(response) { + const text = await response.text(); + if (!text) { + return null; + } + try { + return JSON.parse(text); + } catch (error) { + return { ok: false, error: String(error), text }; + } + } + + function errorMessage(error) { + return error && error.message ? error.message : String(error); + } + + function activeTaskIdFromPayload(payload) { + const envelope = extractPipelineEnvelope(payload); + const envelopeTaskId = taskIdOf(envelope || {}); + if (envelopeTaskId) { + return envelopeTaskId; + } + if (payload && payload.result && typeof payload.result === "object") { + return payload.result.taskId || payload.result.task_id || payload.result.id || ""; + } + if (payload && payload.task && typeof payload.task === "object") { + return payload.task.taskId || payload.task.task_id || payload.task.id || ""; + } + return taskIdOf(payload || {}) || ""; + } + + function isWaitingForInputPayload(payload, state) { + const envelope = extractPipelineEnvelope(payload); + return ( + Boolean(state && state.pendingInput) || + (state && state.status === "waiting_input") || + eventTypeOf(envelope || {}) === "input_required" || + normalizeStatus((envelope && envelope.status) || "") === "waiting_input" + ); + } + + function waitForNextPaint() { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => resolve()); + return; + } + if (typeof window !== "undefined" && typeof window.setTimeout === "function") { + window.setTimeout(resolve, 16); + return; + } + if (typeof setTimeout === "function") { + setTimeout(resolve, 0); + return; + } + resolve(); + }); + } + + function reduceControllerPayload(payload) { + const currentState = ensureState(); + const nextState = reducePipelinePayload(currentState, payload); + const activeTaskId = activeTaskIdFromPayload(payload); + if (!nextState.normalHandoffReady && activeTaskId) { + nextState.activeTaskId = activeTaskId; + } + controller.state = nextState; + renderAll(); + return nextState; + } + + function handleSseBlock(block) { + const dataLines = String(block || "") + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()); + if (dataLines.length === 0) { + return false; + } + const data = dataLines.join("\n").trim(); + if (!data || data === "[DONE]") { + return false; + } + let payload; + try { + payload = JSON.parse(data); + } catch (error) { + appendDiagnostic("sse", { error: String(error), data }); + showStatus("收到无法解析的 SSE 数据,详情见调试信息。", "error"); + return false; + } + appendDiagnostic("sse", payload); + if (payload && payload.ok === false) { + throw new Error(payload.error || payload.message || "SSE stream reported an error"); + } + const nextState = reduceControllerPayload(payload); + return isWaitingForInputPayload(payload, nextState); + } + + async function consumeSseResponse(response) { + if (!response.ok) { + const errorText = typeof response.text === "function" ? await response.text() : ""; + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + if (!response.body || typeof response.body.getReader !== "function") { + const text = typeof response.text === "function" ? await response.text() : ""; + const blocks = text + .replace(/\r\n/g, "\n") + .split("\n\n") + .filter((block) => block.trim()); + for (const block of blocks) { + const shouldStop = handleSseBlock(block); + await waitForNextPaint(); + if (shouldStop) { + break; + } + } + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let shouldStop = false; + while (!shouldStop) { + const { value, done } = await reader.read(); + if (done) { + buffer += decoder.decode(); + break; + } + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n"); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const block = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + shouldStop = handleSseBlock(block); + await waitForNextPaint(); + if (shouldStop) { + break; + } + boundary = buffer.indexOf("\n\n"); + } + } + if (!shouldStop && buffer.trim()) { + handleSseBlock(buffer); + await waitForNextPaint(); + } + if (shouldStop && typeof reader.cancel === "function") { + await reader.cancel(); + } + } + + async function sendComposerMessage() { + if (!ensureFetchAvailable()) { + return; + } + const state = syncStateFromConnectionControls(); + const composer = byId("composer-input"); + const typedPrompt = composer && "value" in composer ? String(composer.value || "").trim() : ""; + const prompt = typedPrompt || promptForSelectedCandidate(state); + if (!prompt) { + showStatus("请输入需求,或先选择一个方案。", "error"); + return; + } + state.userMessages = Array.isArray(state.userMessages) ? state.userMessages : []; + const userMessageId = `user-${Date.now()}-${state.userMessages.length}`; + state.userMessages.push({ + id: userMessageId, + text: prompt, + placement: userMessagePlacementForState(state), + }); + if (state.normalHandoffReady) { + state.pendingNormalUserMessageId = userMessageId; + } + if (composer && "value" in composer && typedPrompt) { + composer.value = ""; + } + renderAll(); + const payload = buildStreamPayload(state, prompt); + appendDiagnostic("request", { method: "POST", path: "/api/message/stream", payload }); + showStatus("正在发送消息并接收 pipeline 事件...", "info"); + try { + const response = await fetch("/api/message/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + await consumeSseResponse(response); + showStatus(ensureState().pendingInput ? "请选择或补充输入后继续。" : "消息已发送,状态已更新。", "info"); + } catch (error) { + const message = errorMessage(error); + appendDiagnostic("error", { action: "send", error: message }); + showStatus(`消息发送失败:${message}`, "error"); + } + } + + async function healthCheck() { + if (!ensureFetchAvailable()) { + return; + } + const state = syncStateFromConnectionControls(); + const path = `/api/health?${queryString({ serverUrl: state.serverUrl })}`; + appendDiagnostic("request", { method: "GET", path }); + try { + const response = await fetch(path); + const body = await readJsonResponse(response); + appendDiagnostic("request", { method: "GET", path, status: response.status, body }); + showStatus(response.ok ? "连接检查完成。" : `连接检查失败:HTTP ${response.status}`, response.ok ? "info" : "error"); + } catch (error) { + const message = errorMessage(error); + appendDiagnostic("error", { action: "health", error: message }); + showStatus(`连接检查失败:${message}`, "error"); + } + } + + async function fetchState() { + if (!ensureFetchAvailable()) { + return; + } + const state = syncStateFromConnectionControls(); + const taskId = state.activeTaskId || state.pipelineTaskId || ""; + const path = `/api/pipeline/state?${queryString({ + serverUrl: state.serverUrl, + contextId: state.contextId || "", + taskId, + afterSequence: state.lastSequence || 0, + })}`; + appendDiagnostic("request", { method: "GET", path }); + try { + const response = await fetch(path); + const body = await readJsonResponse(response); + appendDiagnostic("state", { status: response.status, body }); + if (body) { + reduceControllerPayload(body); + } + showStatus(response.ok ? "状态已同步。" : `同步状态失败:HTTP ${response.status}`, response.ok ? "info" : "error"); + } catch (error) { + const message = errorMessage(error); + appendDiagnostic("error", { action: "fetchState", error: message }); + showStatus(`同步状态失败:${message}`, "error"); + } + } + + async function cancelTask() { + if (!ensureFetchAvailable()) { + return; + } + const state = syncStateFromConnectionControls(); + const payload = { + serverUrl: state.serverUrl || "", + contextId: state.contextId || "", + taskId: state.activeTaskId || state.pipelineTaskId || "", + }; + appendDiagnostic("request", { method: "POST", path: "/api/task/cancel", payload }); + try { + const response = await fetch("/api/task/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await readJsonResponse(response); + appendDiagnostic("request", { method: "POST", path: "/api/task/cancel", status: response.status, body }); + showStatus(response.ok ? "取消请求已发送。" : `取消任务失败:HTTP ${response.status}`, response.ok ? "info" : "error"); + } catch (error) { + const message = errorMessage(error); + appendDiagnostic("error", { action: "cancel", error: message }); + showStatus(`取消任务失败:${message}`, "error"); + } + } + + function bindEvents() { + if (controller.bound) { + return; + } + const serverInput = byId("server-url"); + const cwdInput = byId("cwd"); + const sendButton = byId("send-button"); + const composer = byId("composer-input"); + const healthButton = byId("health-button"); + const fetchStateButton = byId("fetch-state-button"); + const cancelButton = byId("cancel-button"); + const debugDrawer = byId("debug-drawer"); + const addListener = (element, eventName, handler) => { + if (element && typeof element.addEventListener === "function") { + element.addEventListener(eventName, handler); + } + }; + + addListener(serverInput, "input", syncStateFromConnectionControls); + addListener(cwdInput, "input", syncStateFromConnectionControls); + addListener(sendButton, "click", sendComposerMessage); + addListener(healthButton, "click", healthCheck); + addListener(fetchStateButton, "click", fetchState); + addListener(cancelButton, "click", cancelTask); + addListener(debugDrawer, "toggle", renderAll); + addListener(composer, "keydown", (event) => { + if ((event.key === "Enter" && !event.shiftKey) || (event.key === "Enter" && (event.metaKey || event.ctrlKey))) { + event.preventDefault(); + sendComposerMessage(); + } + }); + controller.bound = Boolean( + serverInput || cwdInput || sendButton || composer || healthButton || fetchStateButton || cancelButton || debugDrawer + ); + } + + function loadDemoCandidates() { + let state = ensureState(); + state = upsertCandidate(state, { + name: "ECS 经典网络方案", + candidateIndex: 0, + summary: "使用 VPC、ECS 与弹性公网 IP 搭建轻量 Web 服务,保留后续扩容空间。", + totalMonthlyCost: "¥33.89/月", + costItems: [ + { name: "ECS", spec: "1vCPU/1GiB", monthly_cost: "¥33.89/月" }, + { name: "EIP", spec: "按量公网带宽", monthly_cost: "按实际流量" }, + ], + }); + state = upsertCandidate(state, { + name: "轻量应用服务器一体化方案", + candidateIndex: 1, + summary: "面向演示、测试与低流量站点,预置应用环境并降低运维复杂度。", + totalMonthlyCost: "¥0/月", + costItems: [ + { name: "轻量应用服务器", spec: "试用规格", monthly_cost: "¥0/月" }, + { name: "基础监控", spec: "默认启用", monthly_cost: "¥0/月" }, + ], + }); + state.steps.intent_parsing.status = "completed"; + state.steps.architecture_planning.status = "completed"; + state.steps.evaluate_candidates.status = "completed"; + state.steps.confirm_and_select.status = "waiting_input"; + state.status = "waiting_input"; + state.pipelineStarted = true; + state.pendingInput = { + kind: "candidate_selection", + prompt: "请选择推荐方案", + options: [ + { id: "0", label: "ECS 经典网络方案" }, + { id: "1", label: "轻量应用服务器一体化方案" }, + ], + }; + controller.state = state; + renderAll(); + return state; + } + + function init() { + ensureState(); + syncConnectionControlsFromState(); + syncStateFromConnectionControls(); + bindEvents(); + renderAll(); + return controller.state; + } + + window.SellingConsoleController = { + init, + renderSteps, + renderPlans, + sendComposerMessage, + healthCheck, + fetchState, + cancelTask, + appendDiagnostic, + renderDebug, + }; + window.SellingConsoleDebug = { + loadDemoCandidates, + state: () => ensureState(), + render: renderAll, + }; + + if (hasDocument()) { + if (document.readyState === "loading" && typeof document.addEventListener === "function") { + document.addEventListener("DOMContentLoaded", init, { once: true }); + } else { + init(); + } + } +})(); diff --git a/scripts/a2a/selling_console_web/design/selling-pipeline-progress-options.html b/scripts/a2a/selling_console_web/design/selling-pipeline-progress-options.html new file mode 100644 index 00000000..78f5d72e --- /dev/null +++ b/scripts/a2a/selling_console_web/design/selling-pipeline-progress-options.html @@ -0,0 +1,1655 @@ + + + + + + 进度控件方案 v61 B 标签对齐 + + + +
+
+
+

v61:B 标签对齐版

+

B1 下方 Step 标题改为跟随节点同一组位置对齐,并移除无信息价值的底部说明文字;D 保持 v60。

+
+
B2 已移除
+
+ +
+
+
+
A. 箭头轨道 基准版
+
保留对照
+
+
+
+
需求理解已完成:识别业务场景、规模和预算。
+
架构规划已完成:拆分网络、计算、访问入口。
+
方案评估已完成:比较成本、复杂度和扩展性。
+
方案选择进行中:正在生成方案卡片并等待确认。
+
确认部署未开始:选定方案后进入部署确认。
+
+
请描述业务场景、访问规模、预算范围或已有资源约束
+
+
+ +
+
+ +
+ + + + + +
+
+
+
+ +
+
+
B1. 虚拟端点补齐
+
可调参数
+
+
+
+ + + + + + + +
+ 需求理解 + 架构规划 + 方案评估 + 方案选择 + 确认部署 +
+
+
请描述业务场景、访问规模、预算范围或已有资源约束
+
+
+ +
+
+
+ +
+ + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
B2. 蓝色脉冲波形 双扫光
+
紧凑双峰
+
+
+
+
+
+
+ +
+
+
需求理解已完成:需求已归纳。
+
架构规划已完成:资源关系已确定。
+
方案评估蓝色线路从上一步进入当前步骤。
+
方案选择两道白色扫光形成更强脉冲感。
+ +
+
+
请描述业务场景、访问规模、预算范围或已有资源约束
+
+
+ +
+
+
+ +
+
+
D1. 输入框融合 带标签
+
保留
+
+
+
+
+
需求理解已完成:需求已归纳。
+
架构规划已完成:资源关系已确定。
+
方案评估已完成:已比较候选方案。
+
方案选择进行中:正在生成方案。
+
确认部署未开始:等待方案选择。
+
+
+
请描述业务场景、访问规模、预算范围或已有资源约束
+
+
+ +
+
+ +
+ + + + + +
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + + diff --git a/scripts/a2a/selling_console_web/index.html b/scripts/a2a/selling_console_web/index.html new file mode 100644 index 00000000..63148bfa --- /dev/null +++ b/scripts/a2a/selling_console_web/index.html @@ -0,0 +1,167 @@ + + + + + + 阿里云售卖 Pipeline Console + + + +
+ +
+ 阿里云 + 售卖 Pipeline Console +
+ + + +
+ 企业账号 + +
+
+ +
+
+
+
+

AI 购买助手

+

售卖 Pipeline

+
+ 等待输入 +
+ + +
+ +
+
+
+ +
+
+ +
+
+ + + +
+
+
+

内容由 AI 生成,方案与价格仅供参考

+
+
+ +
+
+
+

方案预览

+

您的购买方案

+
+
+ +
+ +
+
+ 备选 + 匹配度 86% +
+

高可用标准方案

+

面向生产流量,加入多可用区部署、日志服务与云安全中心。

+
¥ 486.00 / 月
+
+
+
地域
+
华东 1(杭州)
+
+
+
容灾
+
跨可用区
+
+
+
+
+ +
+ 调试面板 +
+
+ + +
+ + + +
+
+
+
+
+ Pipeline Diagnostics +
等待 pipeline 事件...
+
+
+
+
+ + +
+ + + + + diff --git a/scripts/a2a/selling_console_web/styles.css b/scripts/a2a/selling_console_web/styles.css new file mode 100644 index 00000000..e4198e90 --- /dev/null +++ b/scripts/a2a/selling_console_web/styles.css @@ -0,0 +1,2371 @@ +:root { + color-scheme: light; + --aliyun-orange: #ff6a00; + --aliyun-blue: #1677ff; + --ink: #1f2937; + --muted: #667085; + --subtle: #98a2b3; + --line: #d8e3f0; + --line-strong: #b8c7d9; + --surface: #f4f7fb; + --surface-blue: #f2f8ff; + --white: #ffffff; + --success: #0f9f6e; + --blue: var(--aliyun-blue); + --green: #13a36f; + --shadow: 0 8px 22px rgba(31, 41, 55, 0.08); +} + +* { + box-sizing: border-box; +} + +html { + height: 100%; + overflow-x: hidden; +} + +body { + min-height: 100%; + margin: 0; + background: var(--surface); + color: var(--ink); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 14px; + letter-spacing: 0; + overflow-x: hidden; +} + +button, +input, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +summary:focus-visible { + outline: 2px solid var(--aliyun-blue); + outline-offset: 2px; +} + +a { + color: inherit; + text-decoration: none; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 18px; + height: 64px; + max-width: 100vw; + padding: 0 24px; + border-bottom: 1px solid var(--line); + background: var(--white); + box-shadow: 0 1px 2px rgba(31, 41, 55, 0.04); +} + +.icon-button { + width: 36px; + height: 36px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--white); + color: var(--ink); +} + +.brand { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 200px; + white-space: nowrap; +} + +.brand strong { + color: var(--aliyun-orange); + font-size: 22px; + font-weight: 700; +} + +.brand span { + color: var(--muted); + font-weight: 600; +} + +.topbar-nav, +.topbar-links, +.user-summary { + display: flex; + align-items: center; + gap: 14px; + white-space: nowrap; +} + +.topbar-nav a, +.topbar-links a { + color: var(--muted); +} + +.topbar-nav a:hover, +.topbar-links a:hover { + color: var(--aliyun-blue); +} + +.nav-pill { + height: 32px; + border: 1px solid #bed7f5; + border-radius: 16px; + background: var(--surface-blue); + color: #1155a3; + padding: 0 14px; +} + +.topbar-search { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + max-width: 520px; + height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdff; + padding: 0 12px; + color: var(--subtle); +} + +.topbar-search input { + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); +} + +.topbar-search:focus-within { + border-color: var(--aliyun-blue); + box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12); +} + +.user-summary { + margin-left: auto; + color: var(--muted); +} + +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: #edf4ff; + color: var(--aliyun-blue); + font-weight: 700; +} + +.console-shell { + display: grid; + grid-template-columns: minmax(280px, 400px) minmax(0, 1fr) 56px; + gap: 16px; + width: 100%; + max-width: 100vw; + height: calc(100vh - 64px); + padding: 16px; + overflow-x: hidden; +} + +.utility-rail { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.workflow-panel, +.plan-area { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--white); + box-shadow: var(--shadow); +} + +.workflow-panel { + display: flex; + flex-direction: column; + height: calc(100vh - 96px); + min-height: 0; + overflow: hidden; +} + +.panel-heading { + display: none; +} + +.plan-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + border-bottom: 1px solid var(--line); + padding: 20px 22px; +} + +.plan-header { + flex-direction: column; + align-items: stretch; + padding: 18px 20px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--aliyun-blue); + font-size: 12px; + font-weight: 700; +} + +h1, +h2, +p { + margin-top: 0; +} + +.panel-heading h1, +.plan-header h1 { + margin-bottom: 0; + font-size: 22px; + line-height: 1.25; +} + +.status-pill { + border: 1px solid #b7dfd0; + border-radius: 16px; + background: #eefaf5; + color: var(--success); + padding: 6px 12px; + font-weight: 700; +} + +.status-alert { + display: none; + margin: 14px 20px 0; + border: 1px solid #9ec9fb; + border-radius: 8px; + background: var(--surface-blue); + color: #1155a3; + padding: 10px 12px; +} + +.plan-card, +.debug-output, +.debug-drawer pre, +.status-alert, +.normal-handoff-message, +.normal-turn, +.normal-process, +.step-card, +.chat-bubble, +.user-message-text, +.connection-controls input { + overflow-wrap: anywhere; +} + +.step-list { + display: grid; + align-content: start; + align-items: start; + flex: 1 1 auto; + gap: 5px; + min-height: 0; + overflow-y: auto; + padding: 8px 14px; +} + +.chat-message { + display: flex; + align-items: flex-start; + gap: 7px; + min-width: 0; +} + +.chat-message.system { + justify-content: flex-start; +} + +.chat-message.user { + justify-content: flex-end; +} + +.chat-message.user .chat-bubble { + order: 1; + max-width: 82%; +} + +.chat-message.user .chat-avatar { + order: 2; +} + +.chat-bubble { + min-width: 0; + max-width: 100%; +} + +.chat-message.system .chat-bubble { + flex: 1 1 auto; +} + +.chat-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 24px; + height: 24px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0; +} + +.chat-avatar.system { + background: linear-gradient(135deg, #ff6a00 0%, #1677ff 100%); + color: var(--white); +} + +.chat-avatar.user { + border: 1px solid #cfe0f7; + background: #edf4ff; + color: var(--aliyun-blue); +} + +.chat-message.system .step-card, +.chat-message.system .normal-handoff-message { + width: 100%; +} + +.user-message-text { + margin: 0; + border: 1px solid #cfe0f7; + border-radius: 8px; + background: #edf4ff; + color: var(--ink); + padding: 6px 9px; + font-size: 12px; + line-height: 1.45; +} + +.step-card { + display: grid; + grid-template-columns: 24px 1fr; + gap: 6px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdff; + padding: 7px 8px; +} + +.step-card.current { + border-color: #9ec9fb; + background: var(--surface-blue); +} + +.step-card.completed { + align-items: center; + background: #fbfffd; + grid-template-columns: 24px 1fr; + gap: 6px; + padding: 6px 8px; +} + +.step-card.failed, +.step-card.error { + border-color: #f5b5ad; + background: #fff7f5; +} + +.step-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--white); + color: var(--aliyun-blue); + font-size: 11px; + font-weight: 800; +} + +.step-card.completed .step-index { + width: 22px; + height: 22px; +} + +.step-state-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + color: var(--white); + font-size: 9px; + line-height: 1; +} + +.step-state-icon.completed { + background: var(--success); +} + +.step-state-icon.working { + background: var(--aliyun-blue); +} + +.step-state-icon.waiting_input { + background: var(--aliyun-orange); +} + +.step-state-icon.failed, +.step-state-icon.error { + background: #d92d20; +} + +.step-card h2 { + margin-bottom: 0; + font-size: 13px; + font-weight: 750; + line-height: 1.28; +} + +.step-card-body { + min-width: 0; +} + +.step-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + border: 0; + background: transparent; + color: inherit; + padding: 0; + text-align: left; +} + +.step-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + color: var(--subtle); +} + +.step-toggle-icon::before { + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg) translate(-1px, -1px); +} + +.step-toggle-icon.expanded::before { + transform: rotate(225deg) translate(-1px, -1px); +} + +.step-detail { + display: grid; + gap: 6px; + grid-column: 2; + min-width: 0; +} + +.step-status { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.step-event-list { + display: grid; + gap: 5px; + max-height: 180px; + overflow-y: auto; + margin: 0; + padding: 0; + padding-right: 4px; + list-style: none; +} + +.step-waiting-prompt { + margin: 0; + color: var(--ink); + font-size: 12px; + line-height: 1.45; +} + +.step-event-card, +.step-result, +.step-result-list { + margin-bottom: 0; + color: var(--muted); + line-height: 1.5; +} + +.step-event-card { + border-left: 2px solid #9ec9fb; + border-radius: 6px; + background: rgba(255, 255, 255, 0.72); + padding-left: 8px; +} + +.step-event-card.tool_result, +.step-event-card.tool_use { + border-left-color: var(--aliyun-blue); + background: rgba(22, 119, 255, 0.06); +} + +.step-event-card.input_required { + border-left-color: var(--aliyun-orange); + background: rgba(255, 106, 0, 0.06); +} + +.step-event-card.text_delta .step-event-title::after { + content: ""; + display: inline-block; + width: 1px; + height: 1em; + margin-left: 2px; + background: currentColor; + transform: translateY(2px); + animation: typingCaret 0.9s steps(1, end) infinite; +} + +.step-event-label { + display: inline-flex; + margin-bottom: 3px; + color: var(--aliyun-blue); + font-size: 11px; + font-weight: 800; +} + +.step-event-title { + margin-bottom: 4px; + color: var(--ink); + font-size: 12px; + font-weight: 700; + line-height: 1.45; +} + +.step-event-meta, +.step-result-list { + display: grid; + gap: 4px; + margin: 0; +} + +.step-event-meta div, +.step-result-list div { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px; + min-width: 0; +} + +.step-event-meta dt, +.step-result-list dt { + color: var(--subtle); + font-weight: 700; +} + +.step-event-meta dd, +.step-result-list dd { + margin: 0; + color: var(--ink); + overflow-wrap: anywhere; +} + +.step-candidate-progress-list { + display: grid; + gap: 6px; + margin: 0; +} + +.step-candidate-progress { + display: grid; + gap: 3px; + border-left: 2px solid #9ec9fb; + border-radius: 6px; + background: rgba(255, 255, 255, 0.74); + padding: 8px 9px; +} + +.step-candidate-progress-head { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.step-candidate-progress strong { + color: var(--ink); + font-size: 12px; + white-space: nowrap; +} + +.step-candidate-progress span { + color: var(--muted); + font-size: 12px; + min-width: 0; +} + +.step-candidate-progress-head span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.step-candidate-progress p { + margin: 0; + color: var(--ink); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.step-result-options { + display: grid; + gap: 8px; +} + +.step-result-option { + display: grid; + gap: 4px; + border: 1px solid #dbe6f5; + border-radius: 8px; + background: rgba(255, 255, 255, 0.72); + padding: 9px 10px; +} + +.step-result-option strong { + color: var(--ink); + font-size: 12px; +} + +.step-result-option span { + color: var(--muted); + font-size: 12px; +} + +.step-result-option .price { + color: var(--aliyun-orange); + font-weight: 800; +} + +.step-process { + display: grid; + gap: 6px; + border-top: 1px solid #edf2f7; + padding-top: 8px; +} + +.step-process-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--muted); + cursor: pointer; + font-size: 12px; + list-style: none; +} + +.step-process-head::-webkit-details-marker { + display: none; +} + +.step-process-head strong { + color: var(--aliyun-blue); + font-size: 12px; +} + +.step-process-events { + margin-top: 6px; +} + +.step-candidate-result-list { + display: grid; + gap: 8px; +} + +.step-candidate-result { + display: grid; + gap: 6px; + border: 1px solid #dbe6f5; + border-radius: 8px; + background: rgba(255, 255, 255, 0.72); + padding: 8px 9px; +} + +.step-candidate-result-head { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.step-candidate-result-head strong { + color: var(--aliyun-blue); + font-size: 12px; + white-space: nowrap; +} + +.step-candidate-result-head span { + min-width: 0; + color: var(--ink); + font-size: 12px; + font-weight: 800; +} + +.step-candidate-result-summary { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.step-candidate-result-label { + color: var(--aliyun-blue); + font-size: 11px; + font-weight: 800; +} + +.step-candidate-result-template { + color: var(--subtle); + font-size: 12px; + line-height: 1.45; +} + +.step-candidate-result-price { + color: var(--aliyun-orange); + font-size: 12px; + font-weight: 800; +} + +.step-candidate-result-process { + display: grid; + gap: 6px; + border-top: 1px solid #edf2f7; + padding-top: 6px; +} + +.step-candidate-result-process-body { + max-height: 180px; + overflow-y: auto; + padding-right: 4px; +} + +.candidate-choice-list { + display: grid; + gap: 10px; +} + +.pending-input-card { + display: grid; + gap: 10px; + border: 1px solid #ffd0a8; + border-radius: 8px; + background: #fffaf5; + padding: 12px; +} + +.pending-input-card h2 { + margin: 0; + color: var(--aliyun-orange); + font-size: 13px; +} + +.pending-input-prompt { + margin: 0; + color: var(--ink); + line-height: 1.55; +} + +.pending-input-prompt p, +.pending-input-option-description p { + margin: 0; +} + +.pending-input-prompt ul, +.pending-input-prompt ol, +.pending-input-option-description ul, +.pending-input-option-description ol { + display: grid; + gap: 2px; + margin: 4px 0 0; + padding-left: 18px; +} + +.pending-input-prompt a, +.pending-input-option-description a { + color: var(--aliyun-blue); + text-decoration: none; +} + +.pending-input-prompt a:hover, +.pending-input-option-description a:hover { + text-decoration: underline; +} + +.pending-input-options { + display: grid; + gap: 8px; +} + +.pending-input-option { + display: grid; + gap: 4px; + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--white); + color: var(--ink); + padding: 10px 12px; + text-align: left; + line-height: 1.45; +} + +.pending-input-option:hover, +.pending-input-option.selected { + border-color: #9ec9fb; + background: var(--surface-blue); +} + +.pending-input-option span { + color: var(--muted); + font-size: 12px; +} + +.pending-input-option-description { + color: var(--muted); + font-size: 12px; +} + +.template-popover-host { + position: relative; +} + +.template-popover { + position: absolute; + right: 12px; + bottom: 12px; + left: 12px; + z-index: 30; + display: grid; + gap: 8px; + max-height: 260px; + overflow-y: auto; + pointer-events: auto; + visibility: hidden; + border: 1px solid #c7d7eb; + border-radius: 8px; + background: rgba(15, 23, 42, 0.96); + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22); + color: var(--white); + opacity: 0; + padding: 10px 12px; + transform: translateY(4px); + transition: opacity 140ms ease, transform 140ms ease, visibility 0ms linear 140ms; + transition-delay: 0ms, 0ms, 140ms; +} + +.template-popover-host:hover .template-popover, +.template-popover-host:focus-within .template-popover, +.template-popover:hover { + visibility: visible; + opacity: 1; + transform: translateY(0); + transition-delay: 500ms, 500ms, 500ms; +} + +.template-popover-title { + color: #dbeafe; + font-size: 12px; + font-weight: 800; +} + +.template-popover pre { + margin: 0; + color: #f8fafc; + font-size: 11px; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.candidate-choice { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--white); + color: var(--ink); + padding: 12px 14px; + text-align: left; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.candidate-choice:hover, +.candidate-choice.selected { + border-color: #9ec9fb; + background: var(--surface-blue); +} + +.candidate-subpipeline { + display: grid; + gap: 8px; + margin-top: 12px; + border-top: 1px solid #edf2f7; + padding-top: 12px; +} + +.candidate-subpipeline-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--muted); + cursor: pointer; + font-size: 12px; + list-style: none; +} + +.candidate-subpipeline-head::-webkit-details-marker { + display: none; +} + +.candidate-subpipeline-head strong { + color: var(--aliyun-blue); + font-size: 12px; +} + +.candidate-subpipeline-arrow { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1px solid #c7d7eb; + border-radius: 999px; + color: var(--subtle); + flex: 0 0 auto; +} + +.candidate-subpipeline-arrow::before { + content: ""; + width: 5px; + height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg) translate(-1px, -1px); +} + +.candidate-subpipeline[open] .candidate-subpipeline-arrow::before { + transform: rotate(225deg) translate(-1px, -1px); +} + +.candidate-subpipeline-body { + max-height: 180px; + overflow-y: auto; + padding-right: 4px; +} + +.candidate-substeps { + display: grid; + gap: 6px; +} + +.candidate-substep { + display: grid; + gap: 6px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.62); + padding: 7px 8px; +} + +.candidate-substep-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--muted); + cursor: pointer; + font-size: 12px; + list-style: none; +} + +.candidate-substep-head::-webkit-details-marker { + display: none; +} + +.candidate-substep-head strong { + color: var(--ink); + font-size: 12px; +} + +.candidate-subpipeline-events { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.candidate-subpipeline-event { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: start; + border-left: 2px solid #9ec9fb; + padding-left: 8px; + color: var(--muted); + font-size: 12px; +} + +.candidate-subpipeline-event p { + margin: 0; + color: var(--ink); + overflow-wrap: anywhere; +} + +.candidate-subpipeline-label { + color: var(--subtle); + font-weight: 700; + white-space: nowrap; +} + +.composer { + flex: 0 0 auto; + margin-top: auto; + padding: 6px 14px 10px; +} + +.normal-handoff-message { + display: grid; + gap: 4px; + border: 1px solid #b7ead4; + border-radius: 8px; + background: #f2fbf7; + color: #12734d; + padding: 9px 10px; + font-size: 12px; + line-height: 1.45; +} + +.normal-handoff-message strong { + font-weight: 800; +} + +.normal-handoff-message p { + margin: 0; + color: #12734d; + font-size: 12px; + line-height: 1.45; +} + +.normal-turn { + display: grid; + gap: 6px; + width: 100%; + border: 1px solid #cfe0f7; + border-radius: 8px; + background: #fbfdff; + padding: 8px 10px; + font-size: 12px; + line-height: 1.45; +} + +.normal-turn.working { + border-color: #9ec9fb; + background: var(--surface-blue); +} + +.normal-turn.failed, +.normal-turn.error { + border-color: #f5b5ad; + background: #fff7f5; +} + +.normal-process { + border: 0; + border-bottom: 1px solid #e3ebf6; + padding-bottom: 5px; +} + +.normal-process-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--aliyun-blue); + font-size: 12px; + font-weight: 750; + line-height: 1.3; + list-style: none; + cursor: pointer; +} + +.normal-process-summary::-webkit-details-marker { + display: none; +} + +.normal-process-summary::after { + content: "⌄"; + color: var(--subtle); + font-size: 12px; + transition: transform 0.16s ease; +} + +.normal-process[open] .normal-process-summary::after { + transform: rotate(180deg); +} + +.normal-process-count { + margin-left: auto; + color: var(--muted); + font-size: 11px; + font-weight: 650; +} + +.normal-process-events { + display: grid; + gap: 5px; + max-height: 180px; + margin: 6px 0 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.normal-process-event { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 7px; + align-items: start; + border-left: 2px solid #9ec9fb; + padding-left: 7px; + color: var(--muted); +} + +.normal-process-event-label { + color: var(--subtle); + font-weight: 750; + white-space: nowrap; +} + +.normal-process-event p { + margin: 0; + color: var(--ink); +} + +.normal-answer { + margin: 0; + color: var(--ink); + font-size: 12px; + line-height: 1.5; +} + +.composer-progress { + margin-bottom: 8px; + min-width: 0; +} + +.composer-progress[hidden] { + display: none; +} + +.composer-progress:not([hidden]) { + position: relative; + margin-bottom: 8px; + border-bottom: 1px solid var(--line); + padding-bottom: 8px; +} + +.progress-shell { + display: block; +} + +.composer-progress .tip { + position: absolute; + z-index: 50; + left: 50%; + top: calc(100% + 8px); + width: max-content; + max-width: 196px; + transform: translateX(-50%) translateY(-3px); + padding: 8px 10px; + border-radius: 8px; + background: #111827; + color: #f8fafc; + font-size: 10px; + font-weight: 500; + line-height: 1.45; + white-space: normal; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.22); +} + +.composer-progress .step:hover .tip, +.composer-progress .signal-node:hover .tip, +.composer-progress .fusion-step:hover .tip { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.composer-progress.chevrons { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + height: 32px; + isolation: isolate; +} + +.chevrons .step { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + margin-left: -6px; + padding: 0 10px 0 14px; + clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 0 100%, 8px 50%); + background: #edf2f7; + color: #405066; + font-size: 10px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.chevrons .step:first-child { + margin-left: 0; + border-radius: 7px 0 0 7px; + clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 0 100%); +} + +.chevrons .step:last-child { + border-radius: 0 7px 7px 0; +} + +.chevrons .done { + background: #e9f7f1; + color: #14704d; +} + +.chevrons .active { + z-index: 2; + background: linear-gradient(90deg, #1677ff, #28a4ff); + color: #fff; + box-shadow: 0 4px 11px rgba(22, 119, 255, 0.2); +} + +.chevrons .active::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 110deg, + transparent 0%, + transparent 38%, + rgba(255, 255, 255, 0.5) 50%, + transparent 62%, + transparent 100% + ); + animation: sweep var(--progress-a-sweep-ms, 1800ms) linear infinite; +} + +.signal-circuit { + position: relative; + height: 50px; + padding: 2px 8px 0; + overflow: hidden; + --absorb-duration: 510ms; +} + +.signal-svg { + position: absolute; + inset: 0 8px auto 8px; + width: calc(100% - 16px); + height: 36px; + overflow: visible; +} + +.signal-active-base, +.signal-moving-wave, +.signal-rail, +.signal-done { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.signal-rail { + stroke: #d5dfec; + stroke-width: 2px; +} + +.signal-done { + stroke: var(--green); + stroke-width: 2px; +} + +.signal-active-base { + stroke-width: 1.35px; +} + +.signal-active-in { + stroke: rgba(22, 119, 255, 0.46); +} + +.signal-active-out { + stroke: rgba(143, 155, 174, 0.6); +} + +.signal-moving-wave { + stroke: var(--blue); + stroke-width: 1.2px; + opacity: 0.98; +} + +.signal-node { + position: absolute; + top: 18px; + width: 12px; + height: 12px; + border: 2px solid #7cc8a6; + border-radius: 50%; + background: #fff; + transform: translate(-50%, -50%); + z-index: 2; +} + +.signal-node.active { + width: 15px; + height: 15px; + border-color: var(--blue); + box-shadow: 0 0 0 3px #fff; + overflow: hidden; +} + +.signal-absorb-halo { + position: absolute; + top: 18px; + width: 20px; + height: 20px; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 1; +} + +.signal-absorb-halo::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 999px; + opacity: 0; + transform: scale(0.78); + transform-origin: center; + background: radial-gradient(circle, rgba(64, 217, 255, 0.34) 0 34%, rgba(22, 119, 255, 0.16) 52%, rgba(22, 119, 255, 0) 76%); + filter: blur(0.3px); +} + +.signal-node-core { + position: absolute; + inset: 1.5px; + border-radius: 999px; + background: radial-gradient( + circle at 50% 48%, + rgba(255, 255, 255, 1) 0 12%, + rgba(110, 230, 255, 0.98) 30%, + rgba(22, 119, 255, 0.92) 68%, + rgba(22, 119, 255, 0.16) 100% + ); + opacity: 0; + transform: scale(0.08); + transform-origin: center; + box-shadow: inset 0 0 3px rgba(255, 255, 255, 0.7), 0 0 10px rgba(22, 119, 255, 0.56); +} + +.signal-node-charge { + position: absolute; + inset: 1.5px; + border-radius: 999px; + opacity: 0; + transform: scale(0.76) rotate(-90deg); + transform-origin: center; + background: + conic-gradient( + from 210deg, + rgba(22, 119, 255, 0) 0deg, + rgba(22, 119, 255, 0.22) 42deg, + rgba(64, 217, 255, 0.95) 86deg, + rgba(255, 255, 255, 0.98) 126deg, + rgba(22, 119, 255, 0.92) 178deg, + rgba(22, 119, 255, 0.16) 232deg, + rgba(22, 119, 255, 0) 300deg, + rgba(22, 119, 255, 0) 360deg + ); + box-shadow: inset 0 0 5px rgba(255, 255, 255, 0.45), 0 0 7px rgba(22, 119, 255, 0.44); + filter: saturate(1.08); +} + +.signal-circuit.absorbing .signal-absorb-halo::before { + animation: signalAbsorbGlow var(--absorb-duration) ease-out both; +} + +.signal-circuit.absorbing .signal-node.active .signal-node-charge { + animation: signalNodeChargeRing var(--absorb-duration) cubic-bezier(0.18, 0.78, 0.24, 1) both; +} + +.signal-circuit.absorbing .signal-node.active .signal-node-core { + animation: signalNodeInnerAbsorb var(--absorb-duration) ease-out both; +} + +.signal-circuit.absorbing .signal-node.active { + animation: signalAbsorbCore var(--absorb-duration) ease-out both; +} + +.signal-node.next, +.signal-node.pending { + border-color: #b9c5d4; +} + +.signal-labels { + position: absolute; + left: 0; + right: 0; + top: 32px; + color: #42526a; + font-size: 9px; + font-weight: 650; + text-align: center; + white-space: nowrap; +} + +.signal-labels span { + position: absolute; + top: 0; + transform: translateX(-50%); +} + +.signal-labels .active { + color: var(--blue); + font-weight: 760; +} + +.fusion-label { + --fusion-green-end: 69.6%; + --fusion-blue-start: 69.6%; + --fusion-blue-end: 83.2%; + --fusion-sweep-duration: 1800ms; + position: relative; + display: grid; + grid-template-columns: 1fr; + align-items: center; + min-height: 36px; + padding: 5px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: + linear-gradient(#fff, #fff) padding-box, + linear-gradient( + 90deg, + #13a36f 0 var(--fusion-green-end), + #1677ff var(--fusion-blue-start) var(--fusion-blue-end), + #dce5f2 var(--fusion-blue-end) 100% + ) border-box; + box-shadow: inset 0 1px 0 rgba(22, 119, 255, 0.06); +} + +.fusion-label::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: -1px; + height: 2px; + border-radius: 999px; + background: + linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0) 30%, + rgba(125, 185, 255, 0.34) 42%, + rgba(255, 255, 255, 0.96) 50%, + rgba(125, 185, 255, 0.32) 58%, + rgba(255, 255, 255, 0) 70%, + transparent 100% + ); + background-position: -39.29% 0; + background-repeat: no-repeat; + background-size: 44% 100%; + filter: drop-shadow(0 0 2px rgba(22, 119, 255, 0.3)); + pointer-events: none; + animation: fusionBorderSweepSync var(--fusion-sweep-duration) linear infinite; + animation-iteration-count: 1; + animation-fill-mode: both; +} + +.fusion-label.sweep-reset::before, +.fusion-label.sweep-reset .fusion-step.active::after { + animation: none !important; +} + +.fusion-label.sweep-reset::before { + background-position: -39.29% 0; + opacity: 0.96; +} + +.fusion-label.sweep-reset .fusion-step.active::after { + background-position: 95.45% 0, 0 0; +} + +.fusion-label.sweep-wait::before { + animation: none !important; + opacity: 0; +} + +.fusion-label.sweep-wait .fusion-step.active::after { + animation: none !important; + background: var(--blue); +} + +.fusion-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 4px; + min-width: 0; +} + +.fusion-step { + position: relative; + min-width: 0; + padding-top: 1px; + color: #536175; + font-size: 9px; + font-weight: 650; + text-align: center; + white-space: nowrap; +} + +.fusion-step .label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fusion-step::after { + content: ""; + display: block; + height: 5px; + margin-top: 4px; + border-radius: 999px; + background: #e8edf5; +} + +.fusion-step.done { + color: #14704d; +} + +.fusion-step.done::after { + background: #97d8ba; +} + +.fusion-step.active { + color: #0b62cf; + font-weight: 760; +} + +.fusion-step.active::after { + background: + linear-gradient(100deg, transparent 0 32%, rgba(255, 255, 255, 0.7) 44%, rgba(255, 255, 255, 0.9) 50%, rgba(255, 255, 255, 0.4) 57%, transparent 68%), + var(--blue); + background-size: 210% 100%, 100% 100%; + background-position: 95.45% 0, 0 0; + background-repeat: no-repeat, no-repeat; + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); + animation: fusionBarSweepSync var(--fusion-sweep-duration) linear infinite; + animation-iteration-count: 1; + animation-fill-mode: both; +} + +@keyframes sweep { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} + +@keyframes typingCaret { + 0%, + 49% { opacity: 1; } + 50%, + 100% { opacity: 0; } +} + +@keyframes fusionBarSweepSync { + from { background-position: 95.45% 0, 0 0; } + to { background-position: 4.55% 0, 0 0; } +} + +@keyframes signalAbsorbGlow { + 0% { opacity: 0; transform: scale(0.72); } + 22% { opacity: 0.42; transform: scale(1); } + 100% { opacity: 0; transform: scale(1.9); } +} + +@keyframes signalNodeInnerAbsorb { + 0% { opacity: 0; transform: scale(0.06); } + 22% { opacity: 0.98; transform: scale(1.02); } + 58% { opacity: 0.86; transform: scale(0.9); } + 100% { opacity: 0; transform: scale(0.28); } +} + +@keyframes signalNodeChargeRing { + 0% { opacity: 0; transform: scale(0.72) rotate(-120deg); } + 18% { opacity: 0.96; transform: scale(1) rotate(-20deg); } + 58% { opacity: 0.82; transform: scale(1.02) rotate(140deg); } + 100% { opacity: 0; transform: scale(0.74) rotate(255deg); } +} + +@keyframes signalAbsorbCore { + 0% { box-shadow: 0 0 0 3px #fff; } + 30% { box-shadow: 0 0 0 3px #fff, 0 0 0 5px rgba(22, 119, 255, 0.2), 0 0 14px rgba(22, 119, 255, 0.56); } + 100% { box-shadow: 0 0 0 3px #fff; } +} + +@keyframes fusionBorderSweepSync { + 0% { background-position: -39.29% 0; opacity: 0.96; } + 100% { background-position: 139.29% 0; opacity: 0.96; } +} + +#composer-input { + width: 100%; + min-height: 40px; + resize: none; + border: 0; + border-radius: 0; + outline: 0; + background: transparent; + color: var(--ink); + padding: 0; + line-height: 1.45; +} + +#composer-input:focus { + box-shadow: none; +} + +.composer-box { + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdff; + padding: 10px 10px 9px; +} + +.composer-box:focus-within { + border-color: var(--aliyun-blue); + box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.1); +} + +.composer-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 8px; +} + +.composer-tools, +.composer-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.primary-button, +.secondary-button { + min-height: 36px; + border-radius: 8px; + padding: 0 16px; + font-weight: 700; +} + +.primary-button { + border: 1px solid var(--aliyun-orange); + background: var(--aliyun-orange); + color: var(--white); +} + +.secondary-button { + border: 1px solid var(--line); + background: var(--white); + color: var(--ink); +} + +.secondary-button:hover { + border-color: #9ec9fb; + color: var(--aliyun-blue); +} + +.composer .compact-button { + min-height: 32px; + padding: 0 12px; + border-radius: 6px; + font-size: 13px; +} + +.icon-only-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.icon-only-button { + width: 32px; + height: 32px; + min-height: 32px; + border: 0; + background: transparent; + color: var(--ink); +} + +.send-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + min-height: 36px; + border-radius: 8px; + padding: 0; +} + +.composer-divider { + width: 1px; + height: 24px; + background: var(--line); +} + +.attachment-icon { + position: relative; + width: 15px; + height: 20px; + transform: rotate(42deg); +} + +.attachment-icon::before, +.attachment-icon::after { + content: ""; + position: absolute; + border: 2px solid currentColor; + border-bottom: 0; + border-radius: 999px 999px 0 0; +} + +.attachment-icon::before { + inset: 1px 2px 6px; +} + +.attachment-icon::after { + inset: 5px 5px 8px; +} + +.send-icon { + position: relative; + width: 15px; + height: 15px; +} + +.send-icon::before { + content: ""; + position: absolute; + inset: 2px 1px 1px 2px; + border-top: 3px solid var(--white); + border-right: 3px solid var(--white); + transform: rotate(45deg); +} + +.send-icon::after { + content: ""; + position: absolute; + left: 2px; + top: 7px; + width: 12px; + height: 3px; + border-radius: 999px; + background: var(--white); + transform: rotate(-25deg); +} + +.ai-disclaimer { + margin: 8px 0 0; + color: var(--subtle); + font-size: 12px; +} + +.plan-area { + display: flex; + flex-direction: column; + min-height: calc(100vh - 96px); +} + +.connection-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 10px; + width: 100%; +} + +.connection-controls label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.connection-controls input { + width: 100%; + height: 36px; + border: 1px solid var(--line); + border-radius: 8px; + outline: 0; + padding: 0 10px; + color: var(--ink); +} + +.connection-controls input:focus-visible { + border-color: var(--aliyun-blue); + box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12); +} + +.connection-actions { + display: flex; + flex-wrap: wrap; + grid-column: 1 / -1; + justify-content: flex-end; + gap: 10px; +} + +.danger-button { + color: #b42318; +} + +.plans-grid { + display: grid; + align-items: start; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + min-width: 0; + padding: 16px; +} + +.plan-card { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 248px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--white); + padding: 16px; +} + +.plan-card.recommended { + border-color: #9ec9fb; +} + +.plan-card.selected { + border-color: var(--aliyun-blue); + background: var(--surface-blue); + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.14); +} + +.plan-card-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.plan-card-header-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + +.tag { + border-radius: 14px; + background: #fff3eb; + color: var(--aliyun-orange); + padding: 4px 10px; + font-size: 12px; + font-weight: 700; +} + +.tag.muted { + background: #eef2f6; + color: var(--muted); +} + +.score { + color: var(--success); + font-size: 12px; + font-weight: 700; +} + +.plan-status { + border-radius: 999px; + padding: 3px 8px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.plan-status.working { + background: #eaf3ff; + color: var(--aliyun-blue); +} + +.plan-status.completed { + background: #e9f8f1; + color: var(--success); +} + +.plan-status.failed { + background: #fff1f0; + color: #b42318; +} + +.plan-card h2 { + margin-bottom: 10px; + font-size: 18px; +} + +.plan-card p { + margin-bottom: 18px; + color: var(--muted); + line-height: 1.6; +} + +.price { + display: grid; + gap: 4px; + margin-top: auto; + color: var(--aliyun-orange); +} + +.price-label { + color: var(--muted-light); + font-size: 12px; + font-weight: 700; +} + +.price strong { + color: var(--aliyun-orange); + font-size: 24px; + font-weight: 800; +} + +.plan-meta { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin: 16px 0 0; +} + +.plan-meta div { + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdff; + padding: 10px; +} + +.plan-meta dt { + color: var(--subtle); + font-size: 12px; +} + +.plan-meta dd { + margin: 4px 0 0; + color: var(--ink); + font-weight: 700; +} + +.debug-drawer { + margin: auto 16px 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdff; +} + +.debug-drawer summary { + padding: 12px 14px; + color: var(--muted); + font-weight: 700; +} + +.debug-panel { + display: grid; + gap: 14px; + border-top: 1px solid var(--line); + padding: 14px; +} + +.debug-session-info { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + border: 1px solid #e0e7f2; + border-radius: 8px; + background: var(--white); + padding: 10px; +} + +.debug-session-field { + display: grid; + gap: 3px; + min-width: 0; +} + +.debug-session-field span { + color: var(--subtle); + font-size: 10px; + font-weight: 700; +} + +.debug-session-field code { + overflow: hidden; + color: var(--ink); + font-family: inherit; + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress-debug-panel { + display: grid; + gap: 12px; +} + +.progress-debug-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.progress-debug-title strong { + font-size: 13px; +} + +.progress-debug-title span { + color: var(--subtle); + font-size: 12px; +} + +.progress-variant-switch { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.progress-variant-switch button { + min-width: 0; + min-height: 32px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--white); + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.progress-variant-switch button.selected { + border-color: #8fc2ff; + background: var(--surface-blue); + color: var(--aliyun-blue); +} + +.progress-param-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.progress-param-grid[hidden] { + display: none; +} + +.progress-param { + display: grid; + gap: 6px; + min-width: 0; +} + +.progress-param-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: #46566b; + font-size: 12px; + font-weight: 700; +} + +.progress-param output { + color: var(--aliyun-blue); + font-variant-numeric: tabular-nums; +} + +.progress-param input[type="range"] { + width: 100%; + accent-color: var(--aliyun-blue); +} + +.step-switch { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 5px; + margin-top: 6px; +} + +.step-switch button { + height: 22px; + border: 1px solid #cbd8e8; + border-radius: 6px; + background: #fff; + color: #475569; + font-size: 9px; + font-weight: 720; + cursor: pointer; +} + +.step-switch button.active { + border-color: rgba(22, 119, 255, 0.72); + background: rgba(22, 119, 255, 0.1); + color: var(--blue); +} + +.progress-demo-step-control { + margin-top: 0; + padding: 7px 8px; + padding-top: 10px; + border-top: 2px solid rgba(22, 119, 255, 0.78); + border-right: 1px solid #e0e7f2; + border-bottom: 1px solid #e0e7f2; + border-left: 1px solid #e0e7f2; + border-radius: 8px; + background: #fbfdff; +} + +.progress-demo-step-control label { + display: flex; + justify-content: space-between; + gap: 6px; + color: #475569; + font-size: 12px; + font-weight: 650; + line-height: 1.2; +} + +.progress-demo-step-control output { + color: var(--blue); + font-weight: 760; +} + +.debug-output-block { + display: grid; + gap: 8px; +} + +.debug-output-block summary { + cursor: pointer; + color: var(--muted); + font-size: 12px; + font-weight: 700; + line-height: 1.35; +} + +.debug-drawer pre { + margin: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--white); + color: var(--muted); + overflow: auto; + padding: 14px; +} + +.utility-rail { + padding-top: 8px; +} + +.utility-rail button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border: 1px solid var(--line); + border-radius: 50%; + background: var(--white); + color: var(--muted); + box-shadow: 0 4px 12px rgba(31, 41, 55, 0.06); +} + +.utility-rail button:hover { + border-color: #9ec9fb; + color: var(--aliyun-blue); +} + +@media (max-width: 1180px) { + .topbar-links { + display: none; + } + + .console-shell { + grid-template-columns: minmax(240px, 347px) minmax(0, 1fr); + } + + .utility-rail { + display: none; + } +} + +@media (max-width: 980px) { + .topbar { + flex-wrap: wrap; + height: auto; + min-height: 64px; + padding: 12px 16px; + } + + .brand { + min-width: 170px; + } + + .topbar-search { + order: 10; + width: 100%; + max-width: none; + } + + .topbar-nav { + display: none; + } + + .console-shell { + grid-template-columns: 1fr; + height: auto; + min-height: calc(100vh - 64px); + padding: 12px; + } + + .workflow-panel, + .plan-area { + height: auto; + min-height: auto; + } + + .panel-heading, + .plan-header { + flex-direction: column; + } + + .connection-controls, + .plans-grid { + grid-template-columns: 1fr; + } + + .composer-progress { + grid-template-columns: repeat(5, minmax(48px, 1fr)); + overflow-x: auto; + } + + .connection-actions { + justify-content: stretch; + } + + .connection-actions .secondary-button { + flex: 1 1 140px; + } +} + +@media (max-width: 560px) { + .topbar { + gap: 10px; + } + + .user-summary { + width: 100%; + justify-content: space-between; + } + + .composer-toolbar, + .composer-actions { + align-items: stretch; + flex-direction: column; + } + + .primary-button, + .secondary-button { + width: 100%; + } + + .composer-toolbar, + .composer-actions { + align-items: center; + flex-direction: row; + } + + .composer .secondary-button { + width: auto; + } + + .composer .send-icon-button { + width: 36px; + } + + .composer .icon-only-button { + width: 32px; + } + + .plan-meta { + grid-template-columns: 1fr; + } +} diff --git a/scripts/repl/e2e/README.md b/scripts/repl/e2e/README.md new file mode 100644 index 00000000..439e5481 --- /dev/null +++ b/scripts/repl/e2e/README.md @@ -0,0 +1,19 @@ +# REPL Pipeline E2E Runner + +This directory contains real terminal end-to-end helpers for pipeline behavior. The runner drives the REPL through a real PTY and is POSIX-only because it uses `pexpect`. + +Run from the repository root: + +```bash +uv run python scripts/repl/e2e/run_pipeline_scenarios.py --help +``` + +By default, run artifacts are written under the system temporary directory, in: + +```text +iac-code-repl-e2e-runs//--/ +``` + +Use `--run-dir` to choose a fixed collection directory for local debugging or CI smoke artifacts. + +The runner is for manual or smoke validation. It uses the developer's configured provider and may call real Alibaba Cloud tools when `--allow-real-cloud` is enabled. It must not require real LLMs or real cloud credentials in automated unit tests; pytest coverage for this directory is limited to pure helpers and argument behavior. diff --git a/scripts/repl/e2e/README.zh-CN.md b/scripts/repl/e2e/README.zh-CN.md new file mode 100644 index 00000000..16378b75 --- /dev/null +++ b/scripts/repl/e2e/README.zh-CN.md @@ -0,0 +1,150 @@ +# REPL Pipeline E2E + +本目录包含通过真实交互式终端回归 pipeline 功能的脚本。它和 +`scripts/a2a/e2e/run_recovery_scenarios.py` 目标相同,都是回归 pipeline;区别是这里走真实 +REPL / PTY 入口,而 A2A runner 走 JSON-RPC / SSE 入口。 + +## 重要说明 + +- 默认使用当前用户真实 `~/.iac-code` 配置。 +- 会调用真实 LLM provider。 +- 带 `--allow-real-cloud` 的 pipeline 场景可能调用真实阿里云工具和凭证。 +- 不属于普通 `make test`,也不会在 pytest 中执行真实场景。 +- 该 runner 通过 `pexpect` 使用真实 PTY,仅支持 POSIX 环境;Windows 会提前报错,不作为本脚本支持目标。 + +## 快速开始 + +```bash +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/repl/e2e/run_pipeline_scenarios.py \ + --allow-real-cloud \ + --scenario scenario1 +``` + +指定 provider/model 但不写入 `settings.yml`: + +```bash +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/repl/e2e/run_pipeline_scenarios.py \ + --allow-real-cloud \ + --provider dashscope \ + --model qwen3.6-plus \ + --scenario scenario1 +``` + +## 场景 + +| 场景 | 覆盖 | +| --- | --- | +| `scenario1` | 通过 REPL 完成 VSwitch pipeline、候选方案选择、handoff normal chat | +| `ask-waiting` | 通过 REPL 回复澄清问题后继续 pipeline,并完成 VSwitch 创建 | +| `ask-waiting-resume` | ask user question 等待时杀进程,重启后重放问题并继续 | +| `image-initial` | 首轮用户输入通过 bracketed paste 粘贴静态 `initial.png` 图片,随后选择候选并完成 VSwitch 创建 | +| `image-ask-waiting-resume` | ask user question 等待时杀进程,`--continue` 恢复后通过静态图片回答澄清问题并继续 | +| `image-selection-waiting-resume` | 首轮图片启动 pipeline,candidate selection 等待时杀进程,重启后恢复选择 UI 并继续 | +| `image-normal-handoff` | pipeline handoff 到 normal chat 后,通过静态图片追问“你刚才创建了什么” | +| `image-interrupt` | evaluate candidates 阶段发送 Esc 后,通过静态图片输入回退到安全组的 interrupt 指令 | +| `selection-waiting-resume` | candidate selection 等待时杀进程,重启后恢复选择 UI 并继续 | +| `selection-invalid-then-valid` | candidate selection 中先发送无效选择,再发送有效选择并完成 | +| `evaluate-resume` | evaluate candidates 阶段杀进程,重启后重放中断点,发送 `continue` 后继续到选择并完成 | +| `rollback-step2` | architecture planning 中发送 Esc 和回退指令,验证 streaming interrupt 路径 | +| `rollback-step3` | pipeline 中发送 Esc 和回退指令,验证 REPL hard interrupt 路径 | +| `rollback-step4-selection` | candidate selection 中发送 Esc 和回退指令,验证 selection tabs interrupt 路径 | +| `rollback-step5-cleanup` | deploying 创建真实 ROS stack 后回退,验证旧 stack 被 cleanup 删除、新 stack 保留 | +| `rollback-step5-cleanup-recovery` | cleanup 删除中杀进程,`--continue` 恢复后验证 cleanup 重新触发并完成 | + +## 验收标准 + +脚本的通过条件不是“进程退出 0”或“某个 regex 被等到”本身,而是 `summary.json` 里的所有 +`checks` 都为 `true`。其中 `acceptance:` 前缀的检查项来自 PTY transcript,是回归验收标准: + +- 通用:必须捕获到 PTY transcript,且 transcript 中不能出现 traceback、pexpect EOF/TIMEOUT、权限拒绝等终端错误。 +- `scenario1`:必须展示 candidate selection,完成 pipeline,并在 PTY transcript 中出现 VSwitch 证据(例如 `VSwitchId`、`vsw-...` 或交换机 ID);进入 normal chat 后,`你刚才创建了什么` 的回答必须提到 VSwitch/交换机,不能只验证“有输出”。 +- `ask-waiting`:必须展示真实 `Ask user question`,回答澄清问题后如果进入 candidate selection,必须继续选择候选并完成 pipeline;最终 PTY transcript 必须出现 VSwitch 证据。 +- `ask-waiting-resume`:必须在 `--continue` 后重放 `Ask user question`;回答后如果进入 candidate selection,必须继续选择候选并完成 pipeline;最终 PTY transcript 必须出现 VSwitch 证据。 +- `image-initial`:必须记录 `initial` 静态图片 fixture 的 paste 事件;图片输入必须启动 pipeline、展示 candidate selection、完成 pipeline,并出现 VSwitch 证据。 +- `image-ask-waiting-resume`:必须在 `--continue` 后重放 `Ask user question`;恢复后的回答必须通过 `ask-first-answer` 静态图片 fixture 输入;如果模型继续追问,runner 会再用 `ask-second-answer` 静态图片回答;最终必须继续到 candidate selection 或 pipeline completed,并出现 VSwitch 证据。 +- `image-selection-waiting-resume`:必须记录 `initial` 静态图片 fixture 的 paste 事件;candidate selection 必须在 `--continue` 后重放;随后通过真实候选 UI 数字键选择并完成 pipeline,最终出现 VSwitch 证据。 +- `image-normal-handoff`:必须完成 pipeline handoff normal chat;normal follow-up 必须通过 `normal-followup` 静态图片 fixture 输入,且回答必须提到 VSwitch/交换机。 +- `image-interrupt`:必须先到达 `Evaluate candidates (3/5)`;发送 Esc 进入 interrupt 输入后,必须通过 `rollback-interrupt` 静态图片 fixture 输入;图片 interrupt 之后必须看到新的 pipeline 进展,回退后的输出必须指向安全组目标且不能指向 VSwitch。 +- `selection-waiting-resume`:必须在 `--continue` 后重放 candidate selection,最终完成 pipeline,并出现 VSwitch 证据。 +- `selection-invalid-then-valid`:必须记录无效选择输入,然后记录有效选择输入,最终完成 pipeline,并出现 VSwitch 证据。 +- `evaluate-resume`:必须先到达 `Evaluate candidates (3/5)`,使用 `--continue` 恢复并重放该步骤;恢复后的普通 REPL prompt ready 后发送 `continue`,随后必须继续到 candidate selection 或 pipeline completed;最终 PTY transcript 必须出现 VSwitch 证据。 +- `rollback-step2`:必须先到达 `Architecture planning (2/5)`;发送回退指令之后必须看到新的 pipeline 进展;回退后的输出必须指向安全组目标,且不能把用户输入 echo 里的“安全组”当作通过证据。 +- `rollback-step3`:必须先到达 `Evaluate candidates (3/5)`;step3 的 parallel tabs 中断输入不要求 transcript 出现普通 `✎` prompt;发送回退指令之后必须看到新的 pipeline 进展(例如新的 `Intent parsing (1/5)`),不能用用户输入 echo 里的“回退”当作通过证据;回退后的输出必须指向安全组目标,且不能指向 VSwitch。 +- `rollback-step4-selection`:必须先到达 `Confirm and select (4/5)`;selection tabs 中断输入同样不要求 transcript 出现普通 `✎` prompt;发送回退指令之后必须看到新的 pipeline 进展;回退后的输出必须指向安全组目标,且不能指向 VSwitch。 +- `rollback-step5-cleanup`:必须到达 deploying 并观察到第一次 CreateStack;回退后 `cleanup.yaml` 必须把第一次 stack 记录为 cleanup target;第二次部署必须创建不同 stack;normal chat 前置 cleanup 必须完成;ROS GetStack 必须确认第一次 stack 已删除,第二次 stack 仍保留。 +- `rollback-step5-cleanup-recovery`:在 `rollback-step5-cleanup` 的基础上,cleanup 开始后必须杀掉 REPL 子进程,随后用 `--continue` 恢复;恢复后必须重新触发 cleanup 并完成;ROS GetStack 同样必须确认旧 stack 删除、新 stack 保留。 + +会创建资源的非 cleanup 场景还必须在当前 REPL session 的 `pipeline/cleanup.yaml` 中观察到 ROS +`CreateStack` 资源,且 StackName 必须是 runner 为当前场景注入的 `iac-e2e-*` test-owned 名称; +teardown 前会通过 ROS GetStack 确认这些 stack 仍存在。场景结束后,runner 会自动删除这些 +observed stack;删除前会再次校验云端 StackName 必须等于 ledger 记录的 test-owned StackName, +避免误删非本轮测试资源。 + +`rollback-step3` 和 `rollback-step4-selection` 会在发送 Esc 后等待第二个 raw-input ready 控制序列,再输入回退指令;这对应 tabs UI 切入中断文本输入行之后的真实 PTY 状态。 + +`rollback-step5-cleanup*` 的通过条件不只看 PTY 文本,还会读取同一个 REPL session 下的 +`pipeline/cleanup.yaml`,并在最后写出 `acceptance-after-cleanup.ros-stack-states.json` 作为真实 ROS +状态快照。这样可以避免“终端看起来清理了,但 ledger 或云端状态不对”的假阳性。 + +pipeline completed 的匹配必须是终态证据,例如 `Pipeline completed`、`CREATE_COMPLETE`、`部署成功` 或 +`Stack ID`;候选方案里的 `Completed` 或“参数选择完成”不能作为通过证据。 + +## 图片场景输入方式 + +REPL image 场景复用 `scripts/a2a/e2e/fixtures/text-images/` 下的静态 PNG fixture,避免每次运行时重新 +生成图片。runner 不依赖系统剪贴板,而是通过 PTY 发送 bracketed paste 序列: + +```text +ESC [ 200 ~ ESC [ 201 ~ +``` + +REPL 会把这个路径交给普通模式相同的 bracketed-paste 处理逻辑,解析图片文件、持久化到 image cache, +并在 prompt 中插入 `[Image #N]`。因此这些场景验证的是真实 REPL 图片入口,而不是测试脚本直接构造 +`ImageBlock`。 + +## 产物 + +默认写入系统临时目录下的 `iac-code-repl-e2e-runs//--/`: + +- `summary.json`:场景结果、检查点、耗时、失败原因。 +- `events.jsonl`:spawn/send/expect/terminate 等黑盒终端事件。 +- `child.env.json`:子进程环境摘要,敏感值会被脱敏。 +- `transcript.raw.log`:脱敏后的原始终端 transcript。 +- `transcript.normalized.log`:去 ANSI/control 字符后的 transcript,便于 diff 和排查。 +- `acceptance-after-cleanup.ros-stack-states.json`:cleanup 场景的 ROS GetStack 快照,敏感值会被脱敏。 + +使用固定目录便于 CI 或本地脚本收集: + +```bash +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/repl/e2e/run_pipeline_scenarios.py \ + --allow-real-cloud \ + --scenario selection-waiting-resume \ + --run-dir "$(python - <<'PY' +import tempfile +from pathlib import Path +print(Path(tempfile.gettempdir()) / 'iac-code-repl-e2e-selection') +PY +)" +``` + +## 常用参数 + +- `--scenario` 可重复传入;默认只跑 `scenario1`。 +- `--cwd` 指定 REPL 子进程工作目录;默认使用 run dir 下的 `workspace/`。 +- `--timeout` 控制普通终端等待。 +- `--stream-timeout` 控制 LLM/pipeline 长等待。 +- `--selection-prompt` 指定候选方案选择输入;默认发送 `1` 选择第一个候选;传空字符串时直接回车确认。 +- `--evaluate-resume-continue-prompt` 指定 `evaluate-resume` 在 `--continue` 重放后用于继续 running sidecar 的输入;默认 `continue`。 +- `--cleanup-continue-prompt` 指定 `rollback-step5-cleanup-recovery` 在 `--continue` 恢复后用于继续 cleanup 的输入;默认只允许删除待清理列表中的 stack,避免误删其他资源。 +- `--permission-prompt-response` 指定工具权限确认菜单的输入;默认 `pageup-enter`(发送 PageUp+Enter,选择第一项 `Yes, allow once`)。 +- `--skip-final-teardown` 调试时跳过测试创建 stack 的最终删除;日常回归不要开启。 +- `--leave-running` 调试时保留子进程,不自动 terminate。 + +## 与 pytest 的关系 + +`tests/repl_e2e/test_run_pipeline_scenarios.py` 只覆盖脚本的纯 helper、参数校验、脱敏、dispatch +流程,不会启动真实 REPL,也不会调用真实 LLM 或云账号。真实回归必须显式运行本目录脚本,并带上 +`--allow-real-cloud`。 diff --git a/scripts/repl/e2e/run_pipeline_scenarios.py b/scripts/repl/e2e/run_pipeline_scenarios.py new file mode 100644 index 00000000..b1a59b98 --- /dev/null +++ b/scripts/repl/e2e/run_pipeline_scenarios.py @@ -0,0 +1,2672 @@ +#!/usr/bin/env python3 +"""Run real interactive REPL pipeline E2E scenarios. + +This runner intentionally drives the public terminal interface through a PTY. +It uses the user's real configuration by default and must not be imported by +ordinary package code. +""" + +from __future__ import annotations + +import argparse +import asyncio +import ipaddress +import json +import os +import re +import shlex +import signal +import tempfile +import time +import uuid +from collections.abc import Callable, Iterable +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +try: + import pexpect +except ImportError: # pragma: no cover - exercised manually when dependency missing + pexpect = None # type: ignore[assignment] + +try: + import yaml +except ImportError: # pragma: no cover - PyYAML is part of the project runtime + yaml = None # type: ignore[assignment] + + +RUN_LOG_ROOT_NAME = "iac-code-repl-e2e-runs" +PTY_SEND_CHUNK_SIZE = 512 +PTY_SEND_CHUNK_DELAY_SECONDS = 0.01 +TEXT_IMAGE_FIXTURE_ROOT = Path(__file__).resolve().parents[2] / "a2a" / "e2e" / "fixtures" / "text-images" +TEXT_IMAGE_FIXTURE_FILENAMES = { + "initial": "initial.png", + "selection": "selection.png", + "normal-followup": "normal-followup.png", + "ask-first-answer": "ask-first-answer.png", + "ask-second-answer": "ask-second-answer.png", + "rollback-interrupt": "rollback-interrupt.png", +} +DEFAULT_INITIAL_PROMPT = "选择一个已有vpc,创建一个vswitch" +DEFAULT_SELECTION_PROMPT = "1" +DEFAULT_ASK_PROMPT = "我有个产品要上线" +DEFAULT_ASK_ANSWER = "我要创建云网络资源;本次只选择已有 VPC 创建一个 VSwitch,不部署 ECS、EIP、SLB 或 Nginx。" +DEFAULT_NORMAL_FOLLOWUP_PROMPT = "你刚才创建了什么" +DEFAULT_ROLLBACK_PROMPT = "回退到 intent_parsing,选择一个已有vpc,创建一个安全组" +DEFAULT_INVALID_SELECTION_PROMPT = "9" +DEFAULT_EVALUATE_RESUME_CONTINUE_PROMPT = "continue" +DEFAULT_CLEANUP_CONTINUE_PROMPT = ( + "只执行上面的回滚清理:仅删除待清理列表中的 stack id,完成后停止,不要删除或检查其他 stack。" +) +DEFAULT_PERMISSION_PROMPT_RESPONSE = "pageup-enter" + +PIPELINE_STARTED_PATTERNS = (r"Pipeline", r"pipeline", r"intent_parsing", r"意图") +CANDIDATE_SELECTION_PATTERNS = ( + r"(?i)Confirm and select\s*\(\d+/\d+\)", + r"(?i)confirm[ _-]+and[ _-]+select\s*\(\d+/\d+\)", + r"确认并选择\s*\(\d+/\d+\)", + r"候选选择\s*\(\d+/\d+\)", +) +CANDIDATE_EVALUATION_PATTERNS = (r"(?i)Evaluate candidates\s*\(\d+/\d+\)", r"evaluate_candidates") +ARCHITECTURE_PLANNING_PATTERNS = (r"(?i)Architecture planning\s*\(\d+/\d+\)", r"architecture_planning") +ASK_PATTERNS = (r"Ask user question", r"请.*输入", r"请.*补充", r"请描述", r"需要.*信息", r"澄清", r"问题") +PIPELINE_COMPLETED_PATTERNS = ( + r"(?i)Pipeline completed", + r"CREATE_COMPLETE", + r"部署成功", + r"Stack ID", + r"(?i)handoff", + r"交接", +) +PIPELINE_FULLY_COMPLETED_PATTERNS = (r"(?i)Pipeline completed\.\s+Normal chat is now active\.",) +POST_ROLLBACK_PROGRESS_PATTERNS = ( + r"●\s*Intent parsing\s*\(1/5\)", + r"●\s*Architecture planning\s*\(2/5\)", + r"Step Intent parsing completed", +) +DEPLOYING_STEP_PATTERNS = (r"●\s*Deploying\s*\(5/5\)", r"CreateStack", r"开始部署") +CREATE_STACK_STARTED_PATTERNS = (r"ROS Stack\(CreateStack", r"CreateStack") +FIRST_STACK_CREATED_PATTERNS = (r"CREATE_COMPLETE", r"Stack ID", r"StackId", r"stack_id") +CLEANUP_STARTED_PATTERNS = ( + r"检测到\s*\d+\s*个回滚残留资源", + r"开始清理流程", + r"回滚清理\s*\[删除中\]", + r"DeleteStack", +) +CLEANUP_RESUME_SUMMARY_PATTERNS = (r"回滚清理恢复", r"回滚清理") +CLEANUP_COMPLETED_PATTERNS = (r"DELETE_COMPLETE", r"回滚清理\s*\[完成\]", r"清理.*完成") +CLEANUP_DEPLOYMENT_FAILURE_PATTERNS = ( + r"\bCREATE_FAILED\b", + r"RouteConflict", + r"StackExists", + r"InvalidCidrBlock", +) +ROS_STACK_DELETED_STATUSES = {"DELETE_COMPLETE"} +REPL_PROMPT_PATTERNS = (r"❯",) +REPL_INPUT_READY_PATTERNS = (r"\x1b\[>4;2m",) +INTERRUPT_INPUT_PATTERNS = (r"✎", r"interrupt", r"输入", r"Judging") +CANDIDATE_SELECTION_READY_PATTERNS = ( + r"Press number keys to select a candidate", + r"Enter to confirm", + r"按数字键.*候选", +) +PERMISSION_PROMPT_PATTERNS = ( + r"Yes, allow once", + r"允许一次", +) +TERMINAL_ERROR_PATTERNS = ( + r"Traceback \(most recent call last\)", + r"pexpect\.(?:TIMEOUT|EOF)", + r"rejected_in_prompt", + r"Permission.*reject", + r"权限.*拒绝", +) +VSWITCH_EVIDENCE_PATTERNS = ( + r"ALIYUN::ECS::VSwitch", + r"VSwitchId", + r"vsw-[A-Za-z0-9]+", + r"交换机\s*ID", +) +VSWITCH_MENTION_PATTERNS = ( + r"(?i)VSwitch", + r"交换机", + r"vsw-[A-Za-z0-9]+", +) +SECURITY_GROUP_EVIDENCE_PATTERNS = ( + r"ALIYUN::ECS::SecurityGroup", + r"SecurityGroupId", + r"sg-[A-Za-z0-9]+", + r"安全组\s*ID", +) +SECURITY_GROUP_MENTION_PATTERNS = ( + r"(?i)SecurityGroup", + r"安全组", + r"sg-[A-Za-z0-9]+", +) +POSITIVE_VSWITCH_TARGET_PATTERNS = ( + r"ALIYUN::ECS::VSwitch", + r"VSwitchId", + r"vsw-[A-Za-z0-9]+", + r"(?:创建|新建|目标资源|资源类型|部署).*?(?:VSwitch|交换机)", +) +NEGATED_VSWITCH_TARGET_LINE_PATTERNS = ( + r"(?i)(?:不|不要|禁止|避免|无需|不再|不能|不得|forbid|forbidden).*?(?:VSwitch|交换机)", + r"(?i)(?:VSwitch|交换机).*?(?:forbid|forbidden|不创建|禁止|不要|无需)", + r"(?i)(?:no|without).*?(?:VSwitch|switch)", + r"(?i)(?:VSwitch|switch).*?(?:no|without)", + r"(?i)(?:从|由|将需求从|把需求从).*?(?:创建|新建).*?(?:VSwitch|交换机).*?(?:改为|变更为|切换为|转为).*?(?:SecurityGroup|安全组)", + r"(?i)from.*?(?:create|creating).*?(?:VSwitch|switch).*?to.*?(?:SecurityGroup|security group)", + r'(?i)"product"\s*:\s*"VSwitch".*?"action"\s*:\s*"forbid"', +) +NEGATED_VSWITCH_TARGET_SPAN_PATTERNS = ( + r"(?is)(?:从|由|将需求从|把需求从).{0,80}(?:创建|新建).{0,80}(?:VSwitch|交换机).{0,160}(?:改为|变更为|切换为|转为).{0,80}(?:SecurityGroup|安全组)", + r"(?is)from.{0,80}(?:create|creating).{0,80}(?:VSwitch|switch).{0,160}to.{0,80}(?:SecurityGroup|security group)", +) +ARCHITECTURE_PLANNING_HEADING_PATTERNS = (r"●\s*Architecture planning\s*\(2/5\)",) +EVALUATE_CANDIDATES_HEADING_PATTERNS = (r"●\s*Evaluate candidates\s*\(3/5\)",) +ASK_USER_QUESTION_HEADING_PATTERNS = (r"●\s*Ask user question",) + +STACK_CREATING_SCENARIOS = frozenset( + { + "scenario1", + "ask-waiting", + "ask-waiting-resume", + "image-initial", + "image-ask-waiting-resume", + "image-selection-waiting-resume", + "image-normal-handoff", + "selection-waiting-resume", + "selection-invalid-then-valid", + "evaluate-resume", + } +) + + +@dataclass +class ScenarioRunResult: + scenario: str + run_dir: str + passed: bool + checks: dict[str, bool] + elapsed_seconds: float + abort_reason: str = "" + notes: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class CleanupNetworkTarget: + vpc_id: str + vpc_cidr: str + zone_id: str + vswitch_cidr: str + rollback_vswitch_cidr: str + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run interactive REPL pipeline E2E scenarios.") + parser.add_argument( + "--scenario", + action="append", + choices=sorted(_SCENARIOS), + help="Scenario to run. Can be repeated. Defaults to scenario1.", + ) + parser.add_argument("--allow-real-cloud", action="store_true") + parser.add_argument("--cwd", default="", help="Child process cwd. Defaults to /workspace.") + parser.add_argument("--run-root", default=str(Path(tempfile.gettempdir()) / RUN_LOG_ROOT_NAME)) + parser.add_argument("--run-dir", default="", help="Explicit run dir. Only valid with one scenario.") + parser.add_argument("--python", default="uv run python") + parser.add_argument("--provider", default="") + parser.add_argument("--model", default="") + parser.add_argument("--api-base", default="") + parser.add_argument("--timeout", type=float, default=45.0) + parser.add_argument("--stream-timeout", type=float, default=1800.0) + parser.add_argument("--terminal-width", type=int, default=140) + parser.add_argument("--terminal-height", type=int, default=40) + parser.add_argument("--candidate-selection-ready-timeout", type=float, default=30.0) + parser.add_argument("--leave-running", action="store_true") + parser.add_argument( + "--skip-final-teardown", + action="store_true", + help="Do not delete test-owned ROS stacks after scenario acceptance checks.", + ) + parser.add_argument("--final-teardown-timeout", type=float, default=900.0) + parser.add_argument("--cleanup-vpc-id", default="", help="Existing VPC ID to use for cleanup E2E scenarios.") + parser.add_argument("--cleanup-vpc-cidr", default="", help="CIDR of --cleanup-vpc-id, used only in prompts.") + parser.add_argument("--cleanup-zone-id", default="", help="Zone ID to use for cleanup E2E scenarios.") + parser.add_argument( + "--cleanup-vswitch-cidr", + default="", + help="Free VSwitch CIDR to use for the first stack in cleanup E2E scenarios.", + ) + parser.add_argument( + "--cleanup-rollback-vswitch-cidr", + default="", + help="Different free VSwitch CIDR to use for the post-rollback stack in cleanup E2E scenarios.", + ) + parser.add_argument("--initial-prompt", default=DEFAULT_INITIAL_PROMPT) + parser.add_argument("--selection-prompt", default=DEFAULT_SELECTION_PROMPT) + parser.add_argument( + "--permission-prompt-response", + default=DEFAULT_PERMISSION_PROMPT_RESPONSE, + help="Permission prompt response: pageup-enter, up-enter, enter, or literal text.", + ) + parser.add_argument("--ask-prompt", default=DEFAULT_ASK_PROMPT) + parser.add_argument("--ask-answer", default=DEFAULT_ASK_ANSWER) + parser.add_argument("--normal-followup-prompt", default=DEFAULT_NORMAL_FOLLOWUP_PROMPT) + parser.add_argument("--rollback-prompt", default=DEFAULT_ROLLBACK_PROMPT) + parser.add_argument("--invalid-selection-prompt", default=DEFAULT_INVALID_SELECTION_PROMPT) + parser.add_argument("--evaluate-resume-continue-prompt", default=DEFAULT_EVALUATE_RESUME_CONTINUE_PROMPT) + parser.add_argument("--cleanup-continue-prompt", default=DEFAULT_CLEANUP_CONTINUE_PROMPT) + return parser.parse_args(argv) + + +def _selected_scenarios(args: argparse.Namespace) -> list[str]: + return args.scenario or ["scenario1"] + + +def _validate_scenario_execution(args: argparse.Namespace, scenario: str) -> None: + if scenario in _REAL_CLOUD_SCENARIOS and not args.allow_real_cloud: + raise SystemExit("refusing to run real REPL pipeline scenario without --allow-real-cloud: " + scenario) + + +def _split_python_command(value: str) -> list[str]: + parts = shlex.split(value, posix=(os.name != "nt")) + if not parts: + raise ValueError("--python must not be empty") + return parts + + +def _build_child_env(args: argparse.Namespace) -> dict[str, str]: + env = os.environ.copy() + env["PYTHONUTF8"] = "1" + env["IAC_CODE_MODE"] = "pipeline" + if args.provider: + env["IAC_CODE_PROVIDER"] = args.provider + if args.model: + env["IAC_CODE_MODEL"] = args.model + if args.api_base: + env["IAC_CODE_BASE_URL"] = args.api_base + return env + + +def _redact_sensitive_text(text: str, env: dict[str, str] | None) -> str: + redacted = text + for name, value in (env or {}).items(): + if not value or len(value) < 6: + continue + upper = name.upper() + if any(marker in upper for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL")): + redacted = redacted.replace(value, "") + redacted = re.sub(r"(?i)(api[_ -]?key\s*[:=]\s*)[^\s,'\"}]+", r"\1", redacted) + redacted = re.sub(r"(?i)(authorization\s*[:=]\s*)[^\s,'\"}]+", r"\1", redacted) + redacted = re.sub(r"(?", redacted) + return redacted + + +_ANSI_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +def _normalize_transcript(text: str) -> str: + text = _ANSI_PATTERN.sub("", text) + text = text.replace("\r\n", "\n").replace("\r", "\n") + text = text.replace("\b", "") + return "\n".join(line.rstrip() for line in text.splitlines()) + + +def _compact_text(text: str, *, max_chars: int = 800) -> str: + compact = " ".join(text.split()) + if len(compact) <= max_chars: + return compact + return compact[: max_chars - 3] + "..." + + +def _permission_prompt_response_sequence(value: str) -> str: + if value == "pageup-enter": + return "\x1b[5~\r" + if value == "up-enter": + return "\x1b[A\r" + if value == "enter": + return "\r" + return f"{value}\r" if value else "\r" + + +def _sendline_to_child(child: Any, text: str, *, capture: Callable[[str], None] | None = None) -> None: + if len(text) <= PTY_SEND_CHUNK_SIZE: + child.sendline(text) + return + for offset in range(0, len(text), PTY_SEND_CHUNK_SIZE): + child.send(text[offset : offset + PTY_SEND_CHUNK_SIZE]) + _drain_child_output(child, capture=capture) + time.sleep(PTY_SEND_CHUNK_DELAY_SECONDS) + _drain_child_output(child, capture=capture) + child.sendline("") + + +def _drain_child_output(child: Any, *, capture: Callable[[str], None] | None = None) -> None: + reader = getattr(child, "read_nonblocking", None) + if not callable(reader): + return + while True: + try: + text = reader(size=4096, timeout=0) + except Exception as exc: + if pexpect is not None and isinstance(exc, pexpect.TIMEOUT): + return + return + if not text: + return + if capture is not None: + capture(str(text)) + + +def _new_run_dir(root: Path) -> Path: + run_name = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{os.getpid()}-{uuid.uuid4().hex[:8]}" + return root / run_name + + +def _write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8") + + +def _append_jsonl(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(value, ensure_ascii=False, default=str) + "\n") + + +def _redacted_env_summary(env: dict[str, str]) -> dict[str, str]: + keys = ["HOME", "IAC_CODE_CONFIG_DIR", "IAC_CODE_MODE", "IAC_CODE_PROVIDER", "IAC_CODE_MODEL", "IAC_CODE_BASE_URL"] + return {key: _redact_sensitive_text(env[key], env) for key in keys if key in env} + + +def _scenario_run_dir(args: argparse.Namespace, scenario: str) -> Path: + if args.run_dir: + return Path(args.run_dir).expanduser().resolve() + return _new_run_dir(Path(args.run_root).expanduser().resolve() / scenario) + + +class ReplPty: + def __init__(self, *, args: argparse.Namespace, run_dir: Path, cwd: Path, env: dict[str, str]) -> None: + if os.name == "nt": + raise SystemExit("real PTY REPL E2E is POSIX-only") + if pexpect is None: + raise RuntimeError("pexpect is required. Install dependencies with: uv sync --all-extras") + self.args = args + self.run_dir = run_dir + self.cwd = cwd + self.env = env + self.events: list[dict[str, Any]] = [] + self.raw_chunks: list[str] = [] + self.child: Any | None = None + self._live_transcript = False + + @property + def transcript(self) -> str: + return "".join(self.raw_chunks) + + def spawn(self, *, extra_args: list[str] | None = None) -> None: + command = [ + *_split_python_command(self.args.python), + "-m", + "iac_code.cli.main", + "--permission-mode", + "bypass_permissions", + *(extra_args or []), + ] + self.events.append({"type": "spawn", "command": command, "cwd": str(self.cwd), "at": _utc_now()}) + self.child = pexpect.spawn( + command[0], + command[1:], + cwd=str(self.cwd), + env=self.env, + encoding="utf-8", + codec_errors="replace", + timeout=self.args.timeout, + dimensions=(self.args.terminal_height, self.args.terminal_width), + ) + self.child.logfile_read = _TranscriptCapture(self) + self._live_transcript = True + + def sendline(self, text: str) -> None: + transcript_offset = len(self.transcript) + _sendline_to_child(self._require_child(), text, capture=self._capture_child_output_force) + self.events.append( + { + "type": "sendline", + "text": _redact_sensitive_text(text, self.env), + "transcript_offset": transcript_offset, + "at": _utc_now(), + } + ) + + def send(self, text: str, *, label: str = "send") -> None: + transcript_offset = len(self.transcript) + self._require_child().send(text) + self.events.append( + { + "type": label, + "text": _redact_sensitive_text(text, self.env), + "transcript_offset": transcript_offset, + "at": _utc_now(), + } + ) + + def paste_image_fixture(self, image_key: str) -> Path: + path = _text_image_fixture_path(image_key) + transcript_offset = len(self.transcript) + child = self._require_child() + child.send(f"\x1b[200~{path}\x1b[201~") + _drain_child_output(child, capture=self._capture_child_output_force) + self.events.append( + { + "type": "paste-image-fixture", + "image_key": image_key, + "path": _redact_sensitive_text(str(path), self.env), + "transcript_offset": transcript_offset, + "at": _utc_now(), + } + ) + return path + + def expect_any(self, patterns: tuple[str, ...], *, description: str, timeout: float) -> str: + child = self._require_child() + deadline = time.monotonic() + timeout + all_patterns = list(patterns) + list(PERMISSION_PROMPT_PATTERNS) + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"timed out waiting for {description}") + index = child.expect(all_patterns, timeout=remaining) + self._capture_child_output(f"{child.before}{child.after}") + if index < len(patterns): + matched = patterns[index] + self.events.append( + { + "type": "expect", + "description": description, + "pattern": matched, + "passed": True, + "at": _utc_now(), + } + ) + return matched + matched = PERMISSION_PROMPT_PATTERNS[index - len(patterns)] + self.events.append( + { + "type": "permission_prompt", + "description": description, + "pattern": matched, + "at": _utc_now(), + } + ) + self.send( + _permission_prompt_response_sequence(self.args.permission_prompt_response), + label="permission-prompt-response", + ) + except Exception as exc: + self._capture_child_output(str(getattr(child, "before", "") or "")) + tail = _compact_text(_normalize_transcript(self.transcript)[-2000:]) + self.events.append( + { + "type": "expect", + "description": description, + "patterns": list(patterns), + "passed": False, + "error": str(exc), + "tail": _redact_sensitive_text(tail, self.env), + "at": _utc_now(), + } + ) + raise + + def expect_optional(self, patterns: tuple[str, ...], *, description: str, timeout: float) -> bool: + child = self._require_child() + try: + index = child.expect(list(patterns), timeout=timeout) + matched = patterns[index] + self.events.append( + { + "type": "expect", + "description": description, + "pattern": matched, + "passed": True, + "optional": True, + "at": _utc_now(), + } + ) + return True + except Exception as exc: + if pexpect is None or not isinstance(exc, pexpect.TIMEOUT): + tail = _compact_text(_normalize_transcript(self.transcript)[-2000:]) + self.events.append( + { + "type": "expect", + "description": description, + "patterns": list(patterns), + "passed": False, + "optional": True, + "error": str(exc), + "tail": _redact_sensitive_text(tail, self.env), + "at": _utc_now(), + } + ) + raise + tail = _compact_text(_normalize_transcript(self.transcript)[-2000:]) + self.events.append( + { + "type": "expect", + "description": description, + "patterns": list(patterns), + "passed": False, + "optional": True, + "tail": _redact_sensitive_text(tail, self.env), + "at": _utc_now(), + } + ) + return False + + def terminate(self, *, force: bool = False) -> None: + child = self.child + if child is None: + return + try: + if force: + child.kill(signal.SIGKILL) + else: + child.terminate(force=True) + finally: + self._capture_child_output(str(getattr(child, "before", "") or "")) + self.events.append({"type": "terminate", "force": force, "at": _utc_now()}) + + def _capture_child_output(self, text: str) -> None: + if text and not self._live_transcript: + self.raw_chunks.append(text) + + def _capture_child_output_force(self, text: str) -> None: + if text: + self.raw_chunks.append(text) + + def _require_child(self) -> Any: + if self.child is None: + raise RuntimeError("REPL child has not been spawned") + return self.child + + +class _TranscriptCapture: + def __init__(self, pty: ReplPty) -> None: + self._pty = pty + + def write(self, text: str) -> None: + if text: + self._pty.raw_chunks.append(text) + + def flush(self) -> None: + return None + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _redact_json_value(value: Any, env: dict[str, str]) -> Any: + if isinstance(value, str): + return _redact_sensitive_text(value, env) + if isinstance(value, list): + return [_redact_json_value(item, env) for item in value] + if isinstance(value, dict): + redacted: dict[str, Any] = {} + for key, item in value.items(): + key_text = str(key) + upper = key_text.upper() + if any(marker in upper for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION")): + redacted[key_text] = "" + else: + redacted[key_text] = _redact_json_value(item, env) + return redacted + return value + + +def _write_run_artifacts( + *, + run_dir: Path, + env: dict[str, str], + raw_transcript: str, + events: list[dict[str, Any]], + result: ScenarioRunResult, +) -> None: + run_dir.mkdir(parents=True, exist_ok=True) + redacted_raw = _redact_sensitive_text(raw_transcript, env) + normalized = _normalize_transcript(redacted_raw) + (run_dir / "transcript.raw.log").write_text(redacted_raw, encoding="utf-8") + (run_dir / "transcript.normalized.log").write_text(normalized, encoding="utf-8") + _write_json(run_dir / "child.env.json", _redacted_env_summary(env)) + with (run_dir / "events.jsonl").open("w", encoding="utf-8") as handle: + for event in events: + handle.write(json.dumps(_redact_json_value(event, env), ensure_ascii=False, default=str) + "\n") + _write_json(run_dir / "summary.json", _redact_json_value(asdict(result), env)) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + scenarios = _selected_scenarios(args) + if args.run_dir and len(scenarios) != 1: + raise SystemExit("--run-dir can only be used with a single --scenario") + for scenario in scenarios: + _validate_scenario_execution(args, scenario) + results = [_SCENARIOS[scenario](args, scenario) for scenario in scenarios] + return 0 if all(code == 0 for code in results) else 1 + + +def _run_with_pty( + args: argparse.Namespace, + scenario: str, + callback: Callable[[ReplPty, dict[str, bool]], None], +) -> int: + started = time.monotonic() + run_dir = _scenario_run_dir(args, scenario) + workspace_dir = Path(args.cwd).expanduser().resolve() if args.cwd else run_dir / "workspace" + workspace_dir.mkdir(parents=True, exist_ok=True) + env = _build_child_env(args) + pty = ReplPty(args=args, run_dir=run_dir, cwd=workspace_dir, env=env) + checks: dict[str, bool] = {} + notes: list[str] = [] + abort_reason = "" + passed = False + acceptance_applied = False + teardown_applied = False + + try: + pty.spawn() + callback(pty, checks) + _apply_acceptance_checks(scenario, args, pty, checks) + acceptance_applied = True + _teardown_real_cloud_scenario_resources(args=args, scenario=scenario, pty=pty, checks=checks, notes=notes) + teardown_applied = True + passed = all(checks.values()) if checks else True + except BaseException as exc: + abort_reason = f"{type(exc).__name__}: {exc}" + notes.append(abort_reason) + passed = False + finally: + if not acceptance_applied: + try: + _apply_acceptance_checks(scenario, args, pty, checks) + acceptance_applied = True + except BaseException as exc: + notes.append(f"acceptance check failed: {type(exc).__name__}: {exc}") + if acceptance_applied and not teardown_applied: + try: + _teardown_real_cloud_scenario_resources( + args=args, + scenario=scenario, + pty=pty, + checks=checks, + notes=notes, + ) + teardown_applied = True + if passed: + passed = all(checks.values()) if checks else True + except BaseException as exc: + notes.append(f"final teardown failed: {type(exc).__name__}: {exc}") + if passed: + passed = False + if not args.leave_running: + try: + pty.terminate() + except BaseException as exc: + notes.append(f"terminal child termination failed: {type(exc).__name__}: {exc}") + if passed: + passed = False + result = ScenarioRunResult( + scenario=scenario, + run_dir=str(run_dir), + passed=passed, + checks=checks, + elapsed_seconds=round(time.monotonic() - started, 3), + abort_reason=abort_reason, + notes=notes, + ) + _write_run_artifacts(run_dir=run_dir, env=env, raw_transcript=pty.transcript, events=pty.events, result=result) + _print_result(result) + + return 0 if passed else 1 + + +def _print_result(result: ScenarioRunResult) -> None: + print(f"\nREPL pipeline scenario: {result.scenario}") + print(f"run_dir: {result.run_dir}") + if result.abort_reason: + print(f"abort_reason: {_compact_text(result.abort_reason, max_chars=1000)}") + if result.notes: + print("\nnotes:") + for note in result.notes: + print(f" - {_compact_text(note, max_chars=1000)}") + print("\nchecks:") + for name, passed in result.checks.items(): + print(f" {'OK' if passed else 'FAIL'} {name}") + print(f"\nRESULT: {'PASS' if result.passed else 'FAIL'}") + + +def _has_any_pattern(text: str, patterns: tuple[str, ...]) -> bool: + return any(re.search(pattern, text) for pattern in patterns) + + +def _count_pattern(text: str, patterns: tuple[str, ...]) -> int: + return sum(len(re.findall(pattern, text)) for pattern in patterns) + + +def _resume_spawns(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + event + for event in events + if event.get("type") == "spawn" and "--continue" in [str(item) for item in event.get("command", [])] + ] + + +def _event_index(events: list[dict[str, Any]], event_type: str) -> int | None: + for index, event in enumerate(events): + if event.get("type") == event_type: + return index + return None + + +def _event_before(events: list[dict[str, Any]], before_type: str, after_type: str) -> bool: + before = _event_index(events, before_type) + after = _event_index(events, after_type) + return before is not None and after is not None and before < after + + +def _has_sendline_event(events: list[dict[str, Any]], text: str) -> bool: + return any(event.get("type") == "sendline" and event.get("text") == text for event in events) + + +def _has_image_fixture_event(events: list[dict[str, Any]], image_key: str) -> bool: + return any(event.get("type") == "paste-image-fixture" and event.get("image_key") == image_key for event in events) + + +def _has_vswitch_business_evidence(transcript: str) -> bool: + if _has_any_pattern(transcript, VSWITCH_EVIDENCE_PATTERNS): + return True + has_vswitch_text = bool(re.search(r"(?i)VSwitch|交换机", transcript)) + has_deploy_result = bool(re.search(r"Stack ID|Stack 名称|CREATE_COMPLETE|部署成功", transcript)) + return has_vswitch_text and has_deploy_result + + +def _has_vswitch_answer_evidence(text: str) -> bool: + return _has_any_pattern(text, VSWITCH_MENTION_PATTERNS) + + +def _has_security_group_target_evidence(text: str) -> bool: + return _has_any_pattern(text, SECURITY_GROUP_EVIDENCE_PATTERNS + SECURITY_GROUP_MENTION_PATTERNS) + + +def _has_positive_vswitch_target_evidence(text: str) -> bool: + cleaned_text = text + for pattern in NEGATED_VSWITCH_TARGET_SPAN_PATTERNS: + cleaned_text = re.sub(pattern, "", cleaned_text) + positive_context_lines = [ + line for line in cleaned_text.splitlines() if not _has_any_pattern(line, NEGATED_VSWITCH_TARGET_LINE_PATTERNS) + ] + return _has_any_pattern("\n".join(positive_context_lines), POSITIVE_VSWITCH_TARGET_PATTERNS) + + +def _last_event_suffix( + transcript: str, + events: list[dict[str, Any]], + *, + event_type: str, + text: str | None = None, +) -> str: + offset: int | None = None + for event in events: + if event.get("type") != event_type: + continue + if text is not None and event.get("text") != text: + continue + raw_offset = event.get("transcript_offset") + if isinstance(raw_offset, int) and raw_offset >= 0: + offset = raw_offset + if offset is None: + return "" + return transcript[offset:] + + +def _suffix_after_sendline_text( + transcript: str, + events: list[dict[str, Any]], + text: str, +) -> str: + suffix = _normalize_transcript(_last_event_suffix(transcript, events, event_type="sendline", text=text)) + normalized_text = _normalize_transcript(text) + if normalized_text and normalized_text in suffix: + return suffix.split(normalized_text, 1)[1] + return suffix + + +def _suffix_after_image_fixture( + transcript: str, + events: list[dict[str, Any]], + image_key: str, +) -> str: + offset: int | None = None + for event in events: + if event.get("type") != "paste-image-fixture" or event.get("image_key") != image_key: + continue + raw_offset = event.get("transcript_offset") + if isinstance(raw_offset, int) and raw_offset >= 0: + offset = raw_offset + if offset is None: + return "" + return _normalize_transcript(transcript[offset:]) + + +def _add_acceptance_check(checks: dict[str, bool], name: str, passed: bool) -> None: + checks[f"acceptance: {name}"] = bool(passed) + + +def _cleanup_stack_name(run_dir: Path, label: str) -> str: + suffix = Path(run_dir).name.rsplit("-", maxsplit=1)[-1] or "stack" + safe_label = "".join(ch if ch.isalnum() else "-" for ch in label.lower()).strip("-") or "stack" + return f"iac-e2e-{suffix[:12]}-{safe_label}"[:128] + + +def _scenario_stack_name(run_dir: Path, scenario: str) -> str: + suffix = Path(run_dir).name.rsplit("-", maxsplit=1)[-1] or "stack" + safe_scenario = "".join(ch if ch.isalnum() else "-" for ch in scenario.lower()).strip("-") or "scenario" + return f"iac-e2e-{suffix[:12]}-{safe_scenario}"[:128] + + +def _stack_name_constraint(run_dir: Path, scenario: str) -> str: + stack_name = _scenario_stack_name(run_dir, scenario) + return f"本次 CreateStack 的 params.StackName 必须精确等于 `{stack_name}`,禁止使用默认或自动生成 StackName。" + + +def _stack_creating_prompt(text: str, run_dir: Path, scenario: str) -> str: + return f"{text}。{_stack_name_constraint(run_dir, scenario)}" + + +def _text_image_fixture_path(image_key: str) -> Path: + filename = TEXT_IMAGE_FIXTURE_FILENAMES.get(image_key) + if not filename: + raise KeyError(f"unknown text image fixture: {image_key}") + path = (TEXT_IMAGE_FIXTURE_ROOT / filename).resolve() + if not path.is_file(): + raise FileNotFoundError(f"text image fixture not found: {path}") + return path + + +def _submit_image_fixture(pty: ReplPty, image_key: str, *, caption: str = "") -> None: + pty.paste_image_fixture(image_key) + if caption: + pty.sendline(caption) + else: + pty.send("\r", label="submit-image") + + +def _cleanup_network_target_from_args(args: argparse.Namespace) -> CleanupNetworkTarget | None: + if not ( + args.cleanup_vpc_id + and args.cleanup_zone_id + and args.cleanup_vswitch_cidr + and args.cleanup_rollback_vswitch_cidr + ): + return None + return CleanupNetworkTarget( + vpc_id=args.cleanup_vpc_id, + vpc_cidr=args.cleanup_vpc_cidr, + zone_id=args.cleanup_zone_id, + vswitch_cidr=args.cleanup_vswitch_cidr, + rollback_vswitch_cidr=args.cleanup_rollback_vswitch_cidr, + ) + + +def _cleanup_network_prompt_fragment(args: argparse.Namespace, *, rollback: bool) -> str: + target = _cleanup_network_target_from_args(args) + if target is None: + return ( + "必须先读取所选 VPC 的 CIDR,并选择属于该 VPC CIDR 的未占用 VSwitch CIDR;" + "第一次和回退后的第二次部署必须使用两个不同的合法未占用 VSwitch CIDR。" + ) + + vpc_cidr = f"(CIDR `{target.vpc_cidr}`)" if target.vpc_cidr else "" + if rollback: + return ( + f"固定使用已有 VPC `{target.vpc_id}`{vpc_cidr}、可用区 `{target.zone_id}`;" + f"本次重新部署只创建安全组,CreateStack 模板参数必须显式设置 VpcId=`{target.vpc_id}`。" + "禁止创建 VSwitch,禁止在第二个栈中使用 CidrBlock 或模板默认 CidrBlock。" + ) + + return ( + f"固定使用已有 VPC `{target.vpc_id}`{vpc_cidr}、可用区 `{target.zone_id}`、" + f"首个 VSwitch CIDR `{target.vswitch_cidr}`;首次 CreateStack 模板参数必须显式设置 " + f"VpcId=`{target.vpc_id}`、ZoneId=`{target.zone_id}`、CidrBlock=`{target.vswitch_cidr}`。" + "禁止使用模板默认 CidrBlock。" + ) + + +def _cleanup_pipeline_prompt(args: argparse.Namespace, run_dir: Path) -> str: + first_stack_name = _cleanup_stack_name(run_dir, "first") + return ( + f"{args.initial_prompt}。第一次 CreateStack 的 params.StackName 必须精确等于 `{first_stack_name}`," + "禁止使用模板名、候选方案名或 vswitch-in-existing-vpc,也不能复用已有资源栈。" + f"{_cleanup_network_prompt_fragment(args, rollback=False)}" + ) + + +def _cleanup_rollback_prompt(args: argparse.Namespace, run_dir: Path) -> str: + second_stack_name = _cleanup_stack_name(run_dir, "second") + return ( + f"{args.rollback_prompt}。重新部署时 CreateStack 的 params.StackName 必须精确等于 `{second_stack_name}`," + "禁止使用模板名、候选方案名或 vswitch-in-existing-vpc,也不能复用已有资源栈。" + "本次回退后的新方案只创建安全组,不创建 VSwitch。" + f"{_cleanup_network_prompt_fragment(args, rollback=True)}" + ) + + +async def _call_aliyun_api_async(product: str, action: str, params: dict[str, Any]) -> dict[str, Any]: + from iac_code.tools.base import ToolContext + from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi + + result = await AliyunApi().execute( + tool_input={"product": product, "action": action, "params": params}, + context=ToolContext(), + ) + if result.is_error: + raise RuntimeError(_compact_text(result.content, max_chars=1000)) + body = json.loads(result.content) + return body if isinstance(body, dict) else {} + + +def _call_aliyun_api(product: str, action: str, params: dict[str, Any]) -> dict[str, Any]: + return asyncio.run(_call_aliyun_api_async(product, action, params)) + + +def _nested_api_items(data: dict[str, Any], outer_key: str, inner_key: str) -> list[dict[str, Any]]: + outer = data.get(outer_key) + if isinstance(outer, dict): + items = outer.get(inner_key) or outer.get(inner_key.lower()) or [] + else: + items = outer or [] + return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else [] + + +def _find_available_vswitch_cidrs(vpc_cidr: str, used_cidrs: Iterable[str], *, count: int) -> list[str]: + try: + vpc_network = ipaddress.ip_network(vpc_cidr, strict=False) + except ValueError: + return [] + if not isinstance(vpc_network, ipaddress.IPv4Network): + return [] + + used_networks: list[ipaddress.IPv4Network] = [] + for cidr in used_cidrs: + try: + network = ipaddress.ip_network(cidr, strict=False) + except ValueError: + continue + if isinstance(network, ipaddress.IPv4Network): + used_networks.append(network) + + prefixlen = max(24, vpc_network.prefixlen) + available: list[str] = [] + if prefixlen == vpc_network.prefixlen: + if not any(vpc_network.overlaps(used) for used in used_networks): + available.append(str(vpc_network)) + return available + + for subnet in reversed(list(vpc_network.subnets(new_prefix=prefixlen))): + if not any(subnet.overlaps(used) for used in used_networks): + available.append(str(subnet)) + used_networks.append(subnet) + if len(available) >= count: + return available + return available + + +def _find_available_vswitch_cidr(vpc_cidr: str, used_cidrs: Iterable[str]) -> str | None: + cidrs = _find_available_vswitch_cidrs(vpc_cidr, used_cidrs, count=1) + return cidrs[0] if cidrs else None + + +def _discover_cleanup_network_target() -> CleanupNetworkTarget: + vpcs_data = _call_aliyun_api("vpc", "DescribeVpcs", {"PageSize": 50}) + for vpc in _nested_api_items(vpcs_data, "Vpcs", "Vpc"): + vpc_id = str(vpc.get("VpcId") or "") + vpc_cidr = str(vpc.get("CidrBlock") or "") + if not vpc_id or not vpc_cidr or str(vpc.get("Status") or "") != "Available": + continue + + vswitches_data = _call_aliyun_api("vpc", "DescribeVSwitches", {"VpcId": vpc_id, "PageSize": 50}) + vswitches = _nested_api_items(vswitches_data, "VSwitches", "VSwitch") + zone_ids = [str(item.get("ZoneId") or "") for item in vswitches if str(item.get("ZoneId") or "")] + used_cidrs = [str(item.get("CidrBlock") or "") for item in vswitches if str(item.get("CidrBlock") or "")] + vswitch_cidrs = _find_available_vswitch_cidrs(vpc_cidr, used_cidrs, count=2) + if zone_ids and len(vswitch_cidrs) >= 2: + return CleanupNetworkTarget( + vpc_id=vpc_id, + vpc_cidr=vpc_cidr, + zone_id=zone_ids[0], + vswitch_cidr=vswitch_cidrs[0], + rollback_vswitch_cidr=vswitch_cidrs[1], + ) + + raise RuntimeError("No available existing VPC with a free VSwitch CIDR was found for cleanup E2E.") + + +def _ensure_cleanup_network_target(args: argparse.Namespace, run_dir: Path) -> CleanupNetworkTarget: + target = _cleanup_network_target_from_args(args) + if target is None: + target = _discover_cleanup_network_target() + args.cleanup_vpc_id = target.vpc_id + args.cleanup_vpc_cidr = target.vpc_cidr + args.cleanup_zone_id = target.zone_id + args.cleanup_vswitch_cidr = target.vswitch_cidr + args.cleanup_rollback_vswitch_cidr = target.rollback_vswitch_cidr + _write_json(Path(run_dir) / "cleanup-network-target.json", asdict(target)) + return target + + +def _session_id_from_transcript(transcript: str) -> str | None: + patterns = ( + r"\bSession:\s*([0-9a-fA-F][0-9a-fA-F-]{7,})", + r"\bsession_id[\"'\s:=]+([0-9a-fA-F][0-9a-fA-F-]{7,})", + r"\bsession[\"'\s:=]+([0-9a-fA-F][0-9a-fA-F-]{7,})", + ) + for pattern in patterns: + match = re.search(pattern, transcript) + if match: + return match.group(1) + return None + + +def _cleanup_ledger_path(pty: Any) -> Path | None: + explicit = getattr(pty, "cleanup_ledger_path", None) + if explicit: + return Path(explicit) + + cwd = str(getattr(pty, "cwd", "") or "") + if not cwd: + return None + session_id = str(getattr(pty, "session_id", "") or "") or _session_id_from_transcript( + str(getattr(pty, "transcript", "") or "") + ) + try: + from iac_code.services.session_storage import SessionStorage + + storage = SessionStorage() + if session_id: + return Path(storage.session_dir(cwd, session_id)) / "pipeline" / "cleanup.yaml" + + project_dir_for = getattr(storage, "_project_dir_for", None) + if callable(project_dir_for): + project_dir = Path(project_dir_for(cwd)) + candidates = sorted( + project_dir.glob("*/pipeline/cleanup.yaml"), + key=lambda path: path.stat().st_mtime if path.exists() else 0, + reverse=True, + ) + if candidates: + return candidates[0] + except Exception: + return None + return None + + +def _cleanup_ledger_data(pty: Any) -> dict[str, Any]: + inline = getattr(pty, "cleanup_ledger", None) + if isinstance(inline, dict): + return inline + path = _cleanup_ledger_path(pty) + if path is None or yaml is None or not path.exists(): + return {} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, Exception): + return {} + return data if isinstance(data, dict) else {} + + +def _cleanup_ledger_items(pty: Any, key: str) -> list[dict[str, Any]]: + values = _cleanup_ledger_data(pty).get(key) + return [item for item in values if isinstance(item, dict)] if isinstance(values, list) else [] + + +def _is_ros_stack_resource(resource: dict[str, Any]) -> bool: + provider = str(resource.get("provider") or "").lower() + resource_type = str(resource.get("resource_type") or resource.get("resourceType") or "").lower() + return provider == "ros" and resource_type == "stack" + + +def _string_from_mapping(mapping: Any, *keys: str) -> str | None: + if not isinstance(mapping, dict): + return None + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + + +def _unique_strings(values: Iterable[str | None]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str) or not value or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _latest_observed_stack_id(pty: Any, *, exclude: set[str]) -> str | None: + resources = _cleanup_ledger_items(pty, "observed_resources") + for resource in reversed(resources): + if not _is_ros_stack_resource(resource): + continue + action = str(resource.get("observed_action") or resource.get("observedAction") or resource.get("action") or "") + if action and action != "CreateStack": + continue + stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + if stack_id and stack_id not in exclude: + return stack_id + return None + + +def _is_create_stack_observation(resource: dict[str, Any]) -> bool: + action = str(resource.get("observed_action") or resource.get("observedAction") or resource.get("action") or "") + return not action or action == "CreateStack" + + +def _observed_create_stack_resources(pty: Any) -> list[dict[str, Any]]: + resources: list[dict[str, Any]] = [] + seen: set[str] = set() + for resource in _cleanup_ledger_items(pty, "observed_resources"): + if not _is_ros_stack_resource(resource) or not _is_create_stack_observation(resource): + continue + stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + if not stack_id or stack_id in seen: + continue + seen.add(stack_id) + resources.append(resource) + return resources + + +def _observed_create_stack_ids(pty: Any) -> list[str]: + return _unique_strings( + _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + for resource in _observed_create_stack_resources(pty) + ) + + +def _observed_create_stack_names(pty: Any) -> list[str]: + return _unique_strings( + _string_from_mapping(resource, "resource_name", "resourceName", "stack_name", "stackName") + for resource in _observed_create_stack_resources(pty) + ) + + +def _wait_for_latest_observed_stack_id(pty: Any, *, exclude: set[str], timeout: float) -> str: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + stack_id = _latest_observed_stack_id(pty, exclude=exclude) + if stack_id: + return stack_id + time.sleep(0.5) + raise TimeoutError("Timed out waiting for rollback cleanup ledger to observe a ROS stack") + + +def _cleanup_target_stack_ids(pty: Any, *, exclude: set[str]) -> list[str]: + stack_ids: list[str] = [] + for resource in _cleanup_ledger_items(pty, "cleanup_resources"): + if not _is_ros_stack_resource(resource): + continue + if resource.get("cleanup_required") is False or resource.get("cleanupRequired") is False: + continue + stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + if stack_id and stack_id not in exclude: + stack_ids.append(stack_id) + return _unique_strings(stack_ids) + + +def _wait_for_cleanup_target_stack_ids(pty: Any, *, exclude: set[str], timeout: float) -> list[str]: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + stack_ids = _cleanup_target_stack_ids(pty, exclude=exclude) + if stack_ids: + return stack_ids + time.sleep(0.5) + raise TimeoutError("Timed out waiting for rollback cleanup ledger to record target stacks") + + +def _cleanup_resource_for_stack(pty: Any, stack_id: str | None) -> dict[str, Any] | None: + if not stack_id: + return None + for resource in _cleanup_ledger_items(pty, "cleanup_resources"): + if _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") == stack_id: + return resource + return None + + +def _cleanup_resource_completed(resource: dict[str, Any] | None) -> bool: + if not isinstance(resource, dict): + return False + cleanup_status = resource.get("cleanupStatus") or resource.get("cleanup_status") or resource.get("status") + stack_status = resource.get("stackStatus") or resource.get("progressStatus") or resource.get("progress_status") + return cleanup_status == "completed" and stack_status == "DELETE_COMPLETE" + + +def _wait_for_cleanup_resource_status(pty: Any, stack_id: str, statuses: set[str], *, timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resource = _cleanup_resource_for_stack(pty, stack_id) + status = "" + if isinstance(resource, dict): + status = str( + resource.get("cleanup_status") or resource.get("cleanupStatus") or resource.get("status") or "" + ) + if status in statuses: + return + time.sleep(0.5) + raise TimeoutError(f"Timed out waiting for cleanup ledger status {sorted(statuses)} on {stack_id}") + + +def _cleanup_history_has_event(pty: Any, stack_id: str | None, event_types: set[str]) -> bool: + if not stack_id: + return False + for item in _cleanup_ledger_items(pty, "history"): + event_type = str(item.get("type") or item.get("event_type") or item.get("eventType") or "") + if event_type not in event_types: + continue + resource = item.get("resource") + resource_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + resource_id = resource_id or _string_from_mapping(item, "resource_id", "resourceId", "stack_id", "stackId") + if resource_id == stack_id: + return True + return False + + +def _capture_ros_stack_states(pty: Any, stack_ids: Iterable[str], name: str) -> dict[str, dict[str, Any]]: + existing = getattr(pty, "ros_stack_states", None) + states: dict[str, dict[str, Any]] = {} + if isinstance(existing, dict): + states.update({str(key): value for key, value in existing.items() if isinstance(value, dict)}) + + missing = [stack_id for stack_id in _unique_strings(stack_ids) if stack_id not in states] + for stack_id in missing: + region_id = _region_for_stack(pty, stack_id) + states[stack_id] = _get_ros_stack_state( + stack_id=stack_id, + region_id=region_id, + redaction_env=getattr(pty, "env", {}), + ) + + run_dir = getattr(pty, "run_dir", None) + env = getattr(pty, "env", {}) + if run_dir is not None: + _write_json( + Path(run_dir) / f"{name}.ros-stack-states.json", + _redact_json_value(states, env if isinstance(env, dict) else {}), + ) + return states + + +def _fresh_ros_stack_state(pty: Any, stack_id: str) -> dict[str, Any]: + return _get_ros_stack_state( + stack_id=stack_id, + region_id=_region_for_stack(pty, stack_id), + redaction_env=getattr(pty, "env", {}), + ) + + +def _get_ros_stack_state( + *, + stack_id: str, + region_id: str, + redaction_env: dict[str, str] | None, +) -> dict[str, Any]: + try: + from alibabacloud_ros20190910 import models as ros_models + + from iac_code.services.cloud_credentials import CloudCredentials + from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory + + credential = CloudCredentials().get_provider("aliyun") + effective_region = region_id or (credential.region_id if credential is not None else "") + client = RosClientFactory.create(credential, effective_region) + request = ros_models.GetStackRequest(stack_id=stack_id, region_id=effective_region) + response = client.get_stack(request) + body = response.body.to_map() + return { + "stack_id": str(body.get("StackId") or stack_id), + "stack_name": str(body.get("StackName") or ""), + "region_id": effective_region, + "status": str(body.get("Status") or ""), + "status_reason": str(body.get("StatusReason") or ""), + "not_found": False, + } + except Exception as exc: + message = _redact_sensitive_text(str(exc), redaction_env) + return { + "stack_id": stack_id, + "region_id": region_id, + "status": "", + "not_found": _is_ros_stack_not_found(exc), + "error": _compact_text(message, max_chars=1000), + } + + +def _delete_ros_stack( + *, + stack_id: str, + region_id: str, + redaction_env: dict[str, str] | None, +) -> None: + try: + from alibabacloud_ros20190910 import models as ros_models + + from iac_code.services.cloud_credentials import CloudCredentials + from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory + + credential = CloudCredentials().get_provider("aliyun") + effective_region = region_id or (credential.region_id if credential is not None else "") + client = RosClientFactory.create(credential, effective_region) + request = ros_models.DeleteStackRequest(stack_id=stack_id, region_id=effective_region) + client.delete_stack(request) + except Exception as exc: + if _is_ros_stack_not_found(exc): + return + message = _redact_sensitive_text(str(exc), redaction_env) + raise RuntimeError(_compact_text(message, max_chars=1000)) from exc + + +def _wait_for_ros_stack_deleted( + *, + pty: Any, + stack_id: str, + timeout: float, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last_state: dict[str, Any] = {} + while time.monotonic() < deadline: + last_state = _fresh_ros_stack_state(pty, stack_id) + if _ros_stack_deleted(last_state): + return last_state + time.sleep(5) + status = last_state.get("status") or "" + raise TimeoutError(f"Timed out waiting for ROS stack deletion: {stack_id} ({status})") + + +def _is_ros_stack_not_found(exc: BaseException) -> bool: + code = str(getattr(exc, "code", "") or "") + message = str(exc) + combined = f"{code} {message}".lower() + not_found_tokens = ( + "stacknotfound", + "notfound.stack", + "entitynotexist.stack", + "specified stack does not exist", + "stack could not be found", + "stack not found", + ) + return any(token in combined for token in not_found_tokens) + + +def _region_for_stack(pty: Any, stack_id: str) -> str: + for key in ("cleanup_resources", "observed_resources"): + for resource in reversed(_cleanup_ledger_items(pty, key)): + if _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") == stack_id: + region = _string_from_mapping(resource, "region_id", "regionId", "RegionId") + if region: + return region + env = getattr(pty, "env", {}) + return env.get("ALIBABA_CLOUD_REGION_ID", "") if isinstance(env, dict) else "" + + +def _ros_stack_deleted(state: dict[str, Any]) -> bool: + if not isinstance(state, dict): + return False + if state.get("not_found") is True: + return True + return state.get("status") in ROS_STACK_DELETED_STATUSES + + +def _ros_stack_retained(state: dict[str, Any]) -> bool: + if not isinstance(state, dict) or state.get("not_found") is True: + return False + status = state.get("status") + return isinstance(status, str) and bool(status) and not status.startswith("DELETE_") + + +def _ros_stack_states_for_acceptance(pty: Any, stack_ids: Iterable[str], name: str) -> dict[str, dict[str, Any]]: + return _capture_ros_stack_states(pty, _unique_strings(stack_ids), name) + + +def _apply_cleanup_acceptance_checks( + *, + scenario: str, + transcript: str, + events: list[dict[str, Any]], + pty: Any, + checks: dict[str, bool], +) -> None: + first_stack_id = str(getattr(pty, "cleanup_first_stack_id", "") or "") + second_stack_id = str(getattr(pty, "cleanup_second_stack_id", "") or "") + observed_stack_ids = { + stack_id + for stack_id in ( + _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + for resource in _cleanup_ledger_items(pty, "observed_resources") + if _is_ros_stack_resource(resource) + ) + if stack_id + } + cleanup_stack_ids = _cleanup_target_stack_ids(pty, exclude={stack_id for stack_id in [second_stack_id] if stack_id}) + run_dir = Path(getattr(pty, "run_dir", "")) + expected_first_stack_name = _cleanup_stack_name(run_dir, "first") + expected_second_stack_name = _cleanup_stack_name(run_dir, "second") + + _add_acceptance_check( + checks, + "first rollback stack observed", + bool(first_stack_id) and first_stack_id in observed_stack_ids, + ) + _add_acceptance_check( + checks, + "rollback cleanup ledger includes first stack", + bool(first_stack_id) and first_stack_id in cleanup_stack_ids, + ) + _add_acceptance_check(checks, "rollback cleanup target stacks observed", bool(cleanup_stack_ids)) + _add_acceptance_check( + checks, + "second stack created after rollback", + bool(second_stack_id) and second_stack_id != first_stack_id and second_stack_id in observed_stack_ids, + ) + _add_acceptance_check( + checks, + "first rollback stack name matches test stack", + bool(first_stack_id) and _observed_cleanup_stack_name(pty, first_stack_id) == expected_first_stack_name, + ) + _add_acceptance_check( + checks, + "second stack name matches test stack", + bool(second_stack_id) and _observed_cleanup_stack_name(pty, second_stack_id) == expected_second_stack_name, + ) + _add_acceptance_check( + checks, + "cleanup snapshot does not target second stack", + bool(second_stack_id) and _cleanup_resource_for_stack(pty, second_stack_id) is None, + ) + _add_acceptance_check( + checks, + "rollback cleanup completed", + bool(cleanup_stack_ids) + and all( + _cleanup_resource_completed(_cleanup_resource_for_stack(pty, stack_id)) for stack_id in cleanup_stack_ids + ), + ) + _add_acceptance_check( + checks, + "no ROS create failure in cleanup transcript", + not _has_any_pattern(transcript, CLEANUP_DEPLOYMENT_FAILURE_PATTERNS), + ) + + ros_states = _ros_stack_states_for_acceptance( + pty, + [*cleanup_stack_ids, second_stack_id], + "acceptance-after-cleanup", + ) + _add_acceptance_check( + checks, + "ROS first rollback stack deleted", + bool(first_stack_id) and _ros_stack_deleted(ros_states.get(first_stack_id, {})), + ) + _add_acceptance_check( + checks, + "ROS rollback cleanup stacks deleted", + bool(cleanup_stack_ids) + and all(_ros_stack_deleted(ros_states.get(stack_id, {})) for stack_id in cleanup_stack_ids), + ) + _add_acceptance_check( + checks, + "ROS second stack retained", + bool(second_stack_id) and _ros_stack_retained(ros_states.get(second_stack_id, {})), + ) + + if scenario == "rollback-step5-cleanup-recovery": + _add_acceptance_check( + checks, + "cleanup process was killed", + any(event.get("type") == "terminate" and event.get("force") is True for event in events), + ) + _add_acceptance_check(checks, "cleanup resume used --continue", bool(_resume_spawns(events))) + _add_acceptance_check( + checks, + "cleanup retriggered after restart", + bool(_resume_spawns(events)) + and _cleanup_history_has_event( + pty, + first_stack_id, + {"cleanup_started", "cleanup_progress", "cleanup_completed"}, + ), + ) + + +def _owned_cleanup_stack_names(run_dir: Path) -> set[str]: + return {_cleanup_stack_name(run_dir, "first"), _cleanup_stack_name(run_dir, "second")} + + +def _observed_cleanup_stack_ids(pty: Any) -> list[str]: + stack_ids = [ + str(getattr(pty, "cleanup_first_stack_id", "") or ""), + str(getattr(pty, "cleanup_second_stack_id", "") or ""), + ] + stack_ids.extend( + _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + for resource in _cleanup_ledger_items(pty, "observed_resources") + if _is_ros_stack_resource(resource) + ) + return _unique_strings(stack_ids) + + +def _observed_cleanup_stack_name(pty: Any, stack_id: str) -> str: + for resource in reversed(_cleanup_ledger_items(pty, "observed_resources")): + if not _is_ros_stack_resource(resource): + continue + resource_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + if resource_id != stack_id: + continue + return _string_from_mapping(resource, "resource_name", "resourceName", "stack_name", "stackName") + return "" + + +def _apply_stack_creating_acceptance_checks(scenario: str, pty: Any, checks: dict[str, bool]) -> None: + if scenario not in STACK_CREATING_SCENARIOS: + return + stack_ids = _observed_create_stack_ids(pty) + expected_stack_name = _scenario_stack_name(Path(getattr(pty, "run_dir", "")), scenario) + stack_names = _observed_create_stack_names(pty) + _add_acceptance_check(checks, "ROS stack observed in cleanup ledger", bool(stack_ids)) + _add_acceptance_check( + checks, + "ROS stack name is test-owned", + bool(stack_ids) and expected_stack_name in stack_names, + ) + ros_states = _ros_stack_states_for_acceptance(pty, stack_ids, "acceptance-before-teardown") if stack_ids else {} + _add_acceptance_check( + checks, + "ROS created stack retained before teardown", + bool(stack_ids) and any(_ros_stack_retained(ros_states.get(stack_id, {})) for stack_id in stack_ids), + ) + + +def _teardown_cleanup_scenario_resources( + *, + args: argparse.Namespace, + scenario: str, + pty: Any, + checks: dict[str, bool], + notes: list[str], +) -> None: + if scenario not in {"rollback-step5-cleanup", "rollback-step5-cleanup-recovery"}: + return + if args.skip_final_teardown: + notes.append("final teardown skipped by --skip-final-teardown") + return + + run_dir = Path(getattr(pty, "run_dir", "")) + owned_stack_names = _owned_cleanup_stack_names(run_dir) + stack_ids = _observed_cleanup_stack_ids(pty) + if not stack_ids: + checks["teardown: no cleanup scenario stacks leaked"] = True + return + + deletion_failures: list[str] = [] + deleted_stack_ids: list[str] = [] + for stack_id in stack_ids: + state = _fresh_ros_stack_state(pty, stack_id) + if _ros_stack_deleted(state): + continue + + stack_name = str(state.get("stack_name") or "") + if stack_name not in owned_stack_names: + deletion_failures.append( + f"{stack_id} has unexpected stack name {stack_name or ''}; " + f"expected one of {sorted(owned_stack_names)}" + ) + continue + + try: + _delete_ros_stack( + stack_id=stack_id, + region_id=str(state.get("region_id") or _region_for_stack(pty, stack_id)), + redaction_env=getattr(pty, "env", {}), + ) + final_state = _wait_for_ros_stack_deleted(pty=pty, stack_id=stack_id, timeout=args.final_teardown_timeout) + if _ros_stack_deleted(final_state): + deleted_stack_ids.append(stack_id) + else: + deletion_failures.append(f"{stack_id} final status is {final_state.get('status') or ''}") + except Exception as exc: + deletion_failures.append(f"{stack_id}: {type(exc).__name__}: {exc}") + + for failure in deletion_failures: + notes.append(f"final teardown failed: {_compact_text(failure, max_chars=1000)}") + + checks["teardown: cleanup scenario owned ROS stacks deleted"] = not deletion_failures + if deleted_stack_ids: + notes.append(f"final teardown deleted ROS stacks: {', '.join(deleted_stack_ids)}") + + +def _teardown_real_cloud_scenario_resources( + *, + args: argparse.Namespace, + scenario: str, + pty: Any, + checks: dict[str, bool], + notes: list[str], +) -> None: + if scenario in {"rollback-step5-cleanup", "rollback-step5-cleanup-recovery"}: + _teardown_cleanup_scenario_resources(args=args, scenario=scenario, pty=pty, checks=checks, notes=notes) + return + if args.skip_final_teardown: + notes.append("final teardown skipped by --skip-final-teardown") + return + + resources = _observed_create_stack_resources(pty) + if not resources: + checks["teardown: no observed ROS stacks leaked"] = True + return + + deletion_failures: list[str] = [] + deleted_stack_ids: list[str] = [] + expected_scenario_stack_name = _scenario_stack_name(Path(getattr(pty, "run_dir", "")), scenario) + for resource in resources: + stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") + if not stack_id: + continue + expected_stack_name = _string_from_mapping(resource, "resource_name", "resourceName", "stack_name", "stackName") + if expected_stack_name != expected_scenario_stack_name: + deletion_failures.append( + f"{stack_id} has unexpected test-owned stack name {expected_stack_name or ''}; " + f"expected {expected_scenario_stack_name}" + ) + continue + state = _fresh_ros_stack_state(pty, stack_id) + if _ros_stack_deleted(state): + continue + + actual_stack_name = str(state.get("stack_name") or "") + if not expected_stack_name: + deletion_failures.append(f"{stack_id} has no observed stack name in cleanup ledger") + continue + if actual_stack_name != expected_stack_name: + deletion_failures.append( + f"{stack_id} has unexpected stack name {actual_stack_name or ''}; " + f"expected observed name {expected_stack_name}" + ) + continue + + try: + _delete_ros_stack( + stack_id=stack_id, + region_id=str(state.get("region_id") or _region_for_stack(pty, stack_id)), + redaction_env=getattr(pty, "env", {}), + ) + final_state = _wait_for_ros_stack_deleted(pty=pty, stack_id=stack_id, timeout=args.final_teardown_timeout) + if _ros_stack_deleted(final_state): + deleted_stack_ids.append(stack_id) + else: + deletion_failures.append(f"{stack_id} final status is {final_state.get('status') or ''}") + except Exception as exc: + deletion_failures.append(f"{stack_id}: {type(exc).__name__}: {exc}") + + for failure in deletion_failures: + notes.append(f"final teardown failed: {_compact_text(failure, max_chars=1000)}") + + checks["teardown: observed ROS stacks deleted"] = not deletion_failures + if deleted_stack_ids: + notes.append(f"final teardown deleted ROS stacks: {', '.join(deleted_stack_ids)}") + + +def _apply_acceptance_checks( + scenario: str, + args: argparse.Namespace, + pty: Any, + checks: dict[str, bool], +) -> None: + raw_transcript = str(getattr(pty, "transcript", "")) + transcript = _normalize_transcript(raw_transcript) + events = list(getattr(pty, "events", [])) + _add_acceptance_check(checks, "PTY transcript captured", bool(transcript.strip())) + _add_acceptance_check( + checks, + "no terminal error in PTY transcript", + not _has_any_pattern(transcript, TERMINAL_ERROR_PATTERNS), + ) + + if scenario == "scenario1": + normal_answer = _suffix_after_sendline_text(raw_transcript, events, args.normal_followup_prompt) + _add_acceptance_check( + checks, + "candidate selection was shown", + _has_any_pattern(transcript, CANDIDATE_SELECTION_PATTERNS), + ) + _add_acceptance_check(checks, "pipeline completed", _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS)) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + _add_acceptance_check( + checks, + "normal follow-up answered created VSwitch", + _has_vswitch_answer_evidence(normal_answer), + ) + elif scenario == "image-initial": + _add_acceptance_check(checks, "initial image fixture was pasted", _has_image_fixture_event(events, "initial")) + _add_acceptance_check( + checks, + "candidate selection was shown", + _has_any_pattern(transcript, CANDIDATE_SELECTION_PATTERNS), + ) + _add_acceptance_check(checks, "pipeline completed", _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS)) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + elif scenario == "image-ask-waiting-resume": + after_answer = _suffix_after_image_fixture(raw_transcript, events, "ask-first-answer") + _add_acceptance_check( + checks, + "ask user question was replayed after resume", + _count_pattern(transcript, ASK_USER_QUESTION_HEADING_PATTERNS) >= 2, + ) + _add_acceptance_check(checks, "resume used --continue", bool(_resume_spawns(events))) + _add_acceptance_check( + checks, + "ask answer image fixture was pasted", + _has_image_fixture_event(events, "ask-first-answer"), + ) + _add_acceptance_check( + checks, + "ask image answer advanced pipeline after resume", + _has_any_pattern(after_answer or transcript, CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS), + ) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + elif scenario == "image-selection-waiting-resume": + _add_acceptance_check(checks, "initial image fixture was pasted", _has_image_fixture_event(events, "initial")) + _add_acceptance_check( + checks, + "candidate selection was replayed after resume", + _count_pattern(transcript, CANDIDATE_SELECTION_PATTERNS) >= 2, + ) + _add_acceptance_check(checks, "resume used --continue", bool(_resume_spawns(events))) + _add_acceptance_check( + checks, + "pipeline completed after resume", + _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS), + ) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + elif scenario == "image-normal-handoff": + normal_answer = _suffix_after_image_fixture(raw_transcript, events, "normal-followup") + _add_acceptance_check( + checks, + "candidate selection was shown", + _has_any_pattern(transcript, CANDIDATE_SELECTION_PATTERNS), + ) + _add_acceptance_check(checks, "pipeline completed", _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS)) + _add_acceptance_check( + checks, + "normal follow-up image fixture was pasted", + _has_image_fixture_event(events, "normal-followup"), + ) + _add_acceptance_check( + checks, + "normal image follow-up answered created VSwitch", + _has_vswitch_answer_evidence(normal_answer), + ) + elif scenario == "image-interrupt": + after_rollback = _suffix_after_image_fixture(raw_transcript, events, "rollback-interrupt") + _add_acceptance_check( + checks, + "rollback image fixture was pasted", + _has_image_fixture_event(events, "rollback-interrupt"), + ) + _add_acceptance_check( + checks, + "rollback reached evaluate_candidates step", + _has_any_pattern(transcript, EVALUATE_CANDIDATES_HEADING_PATTERNS), + ) + _add_acceptance_check( + checks, + "rollback image produced post-interrupt pipeline progress", + _has_any_pattern(after_rollback, POST_ROLLBACK_PROGRESS_PATTERNS), + ) + _add_acceptance_check( + checks, + "post-rollback target is security group", + _has_security_group_target_evidence(after_rollback), + ) + _add_acceptance_check( + checks, + "post-rollback target is not VSwitch", + not _has_positive_vswitch_target_evidence(after_rollback), + ) + elif scenario == "ask-waiting": + after_answer = _normalize_transcript( + _last_event_suffix(raw_transcript, events, event_type="sendline", text=args.ask_answer) + ) + _add_acceptance_check(checks, "ask user question was shown", "Ask user question" in transcript) + _add_acceptance_check( + checks, + "ask answer advanced pipeline", + _has_any_pattern(after_answer or transcript, CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS), + ) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + elif scenario == "selection-waiting-resume": + continue_spawns = _resume_spawns(events) + _add_acceptance_check( + checks, + "candidate selection was replayed after resume", + _count_pattern(transcript, CANDIDATE_SELECTION_PATTERNS) >= 2, + ) + _add_acceptance_check(checks, "resume used --continue", bool(continue_spawns)) + _add_acceptance_check( + checks, + "pipeline completed after resume", + _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS), + ) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + elif scenario == "rollback-step3": + after_rollback = _suffix_after_sendline_text(raw_transcript, events, args.rollback_prompt) + _add_acceptance_check( + checks, + "rollback reached evaluate_candidates step", + _has_any_pattern(transcript, EVALUATE_CANDIDATES_HEADING_PATTERNS), + ) + _add_acceptance_check( + checks, + "rollback produced post-interrupt pipeline progress", + _has_any_pattern(after_rollback, POST_ROLLBACK_PROGRESS_PATTERNS), + ) + _add_acceptance_check( + checks, + "post-rollback target is security group", + _has_security_group_target_evidence(after_rollback), + ) + _add_acceptance_check( + checks, + "post-rollback target is not VSwitch", + not _has_positive_vswitch_target_evidence(after_rollback), + ) + elif scenario == "rollback-step2": + after_rollback = _suffix_after_sendline_text(raw_transcript, events, args.rollback_prompt) + _add_acceptance_check( + checks, + "rollback reached architecture_planning step", + _has_any_pattern(transcript, ARCHITECTURE_PLANNING_HEADING_PATTERNS), + ) + _add_acceptance_check( + checks, + "rollback produced post-interrupt pipeline progress", + _has_any_pattern(after_rollback, POST_ROLLBACK_PROGRESS_PATTERNS), + ) + _add_acceptance_check( + checks, + "post-rollback target is security group", + _has_security_group_target_evidence(after_rollback), + ) + _add_acceptance_check( + checks, + "post-rollback target is not VSwitch", + not _has_positive_vswitch_target_evidence(after_rollback), + ) + elif scenario == "rollback-step4-selection": + after_rollback = _suffix_after_sendline_text(raw_transcript, events, args.rollback_prompt) + _add_acceptance_check( + checks, + "rollback reached candidate selection step", + _has_any_pattern(transcript, CANDIDATE_SELECTION_PATTERNS), + ) + _add_acceptance_check( + checks, + "rollback produced post-interrupt pipeline progress", + _has_any_pattern(after_rollback, POST_ROLLBACK_PROGRESS_PATTERNS), + ) + _add_acceptance_check( + checks, + "post-rollback target is security group", + _has_security_group_target_evidence(after_rollback), + ) + _add_acceptance_check( + checks, + "post-rollback target is not VSwitch", + not _has_positive_vswitch_target_evidence(after_rollback), + ) + elif scenario == "evaluate-resume": + after_continue = _normalize_transcript( + _last_event_suffix( + raw_transcript, + events, + event_type="sendline", + text=args.evaluate_resume_continue_prompt, + ) + ) + _add_acceptance_check( + checks, + "evaluate_candidates was shown before resume", + _has_any_pattern(transcript, EVALUATE_CANDIDATES_HEADING_PATTERNS), + ) + _add_acceptance_check( + checks, + "evaluate_candidates was replayed after resume", + _count_pattern(transcript, EVALUATE_CANDIDATES_HEADING_PATTERNS) >= 2, + ) + _add_acceptance_check(checks, "resume used --continue", bool(_resume_spawns(events))) + _add_acceptance_check( + checks, + "resume continue input was sent", + _has_sendline_event(events, args.evaluate_resume_continue_prompt), + ) + _add_acceptance_check( + checks, + "pipeline advanced after resume continue", + _has_any_pattern(after_continue or transcript, CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS), + ) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + elif scenario == "ask-waiting-resume": + after_answer = _normalize_transcript( + _last_event_suffix(raw_transcript, events, event_type="sendline", text=args.ask_answer) + ) + _add_acceptance_check( + checks, + "ask user question was replayed after resume", + _count_pattern(transcript, ASK_USER_QUESTION_HEADING_PATTERNS) >= 2, + ) + _add_acceptance_check(checks, "resume used --continue", bool(_resume_spawns(events))) + _add_acceptance_check( + checks, + "ask answer advanced pipeline after resume", + _has_any_pattern(after_answer or transcript, CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS), + ) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + elif scenario == "selection-invalid-then-valid": + _add_acceptance_check( + checks, + "invalid selection input was sent", + _event_index(events, "select-invalid-candidate") is not None, + ) + _add_acceptance_check( + checks, + "valid selection input was sent after invalid input", + _event_before(events, "select-invalid-candidate", "select-default-candidate"), + ) + _add_acceptance_check(checks, "pipeline completed", _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS)) + _add_acceptance_check( + checks, + "VSwitch evidence found in PTY transcript", + _has_vswitch_business_evidence(transcript), + ) + elif scenario in {"rollback-step5-cleanup", "rollback-step5-cleanup-recovery"}: + _add_acceptance_check( + checks, + "deploying step was reached", + _has_any_pattern(transcript, DEPLOYING_STEP_PATTERNS + FIRST_STACK_CREATED_PATTERNS), + ) + _add_acceptance_check(checks, "cleanup started", _has_any_pattern(transcript, CLEANUP_STARTED_PATTERNS)) + _apply_cleanup_acceptance_checks( + scenario=scenario, + transcript=transcript, + events=events, + pty=pty, + checks=checks, + ) + _apply_stack_creating_acceptance_checks(scenario, pty, checks) + + +def _select_default_candidate(pty: ReplPty, args: argparse.Namespace) -> None: + if args.selection_prompt: + pty.send(f"{args.selection_prompt}\r", label="select-default-candidate") + else: + pty.send("\r", label="select-default-candidate") + + +def _expect_initial_prompt(pty: ReplPty, args: argparse.Namespace) -> None: + pty.expect_any(REPL_PROMPT_PATTERNS, description="initial prompt", timeout=args.timeout) + pty.expect_any(REPL_INPUT_READY_PATTERNS, description="prompt input ready", timeout=args.timeout) + + +def _expect_candidate_selection(pty: ReplPty, args: argparse.Namespace, *, description: str) -> None: + pty.expect_any(CANDIDATE_SELECTION_PATTERNS, description=description, timeout=args.stream_timeout) + pty.expect_optional( + CANDIDATE_SELECTION_READY_PATTERNS, + description="candidate selection controls ready", + timeout=args.candidate_selection_ready_timeout, + ) + + +def _expect_raw_input_ready(pty: ReplPty, args: argparse.Namespace, *, description: str) -> None: + pty.expect_any(REPL_INPUT_READY_PATTERNS, description=description, timeout=args.timeout) + + +def _expect_parallel_interrupt_ready(pty: ReplPty, args: argparse.Namespace) -> None: + _expect_raw_input_ready(pty, args, description="parallel interrupt input ready") + + +def _wait_for_cleanup_completed_and_ready(pty: ReplPty, args: argparse.Namespace, first_stack_id: str) -> None: + _wait_for_cleanup_resource_status(pty, first_stack_id, {"completed"}, timeout=args.stream_timeout) + pty.expect_optional( + CLEANUP_COMPLETED_PATTERNS, + description="cleanup completed", + timeout=min(args.timeout, 5.0), + ) + _expect_raw_input_ready(pty, args, description="post-cleanup prompt input ready") + + +def _finish_vswitch_pipeline_after_possible_selection( + pty: ReplPty, + args: argparse.Namespace, + checks: dict[str, bool], + matched_pattern: str, + *, + selection_check: str, + completion_check: str, + completion_description: str, +) -> None: + if matched_pattern in CANDIDATE_SELECTION_PATTERNS: + pty.expect_optional( + CANDIDATE_SELECTION_READY_PATTERNS, + description="candidate selection controls ready after ask", + timeout=args.candidate_selection_ready_timeout, + ) + _select_default_candidate(pty, args) + checks[selection_check] = True + pty.expect_any(PIPELINE_COMPLETED_PATTERNS, description=completion_description, timeout=args.stream_timeout) + checks[completion_check] = True + + +def _expect_post_rollback_security_group_target( + pty: ReplPty, + args: argparse.Namespace, + checks: dict[str, bool], +) -> None: + pty.expect_any( + SECURITY_GROUP_MENTION_PATTERNS, + description="post-rollback security group target visible", + timeout=min(args.stream_timeout, 300.0), + ) + checks["post-rollback security group target visible"] = True + + +def run_scenario1(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario)) + pty.expect_any(PIPELINE_STARTED_PATTERNS, description="pipeline started", timeout=args.stream_timeout) + checks["pipeline started"] = True + _expect_candidate_selection(pty, args, description="candidate selection visible") + checks["candidate selection became visible"] = True + _select_default_candidate(pty, args) + checks["candidate selection input sent"] = True + pty.expect_any( + PIPELINE_FULLY_COMPLETED_PATTERNS, + description="pipeline fully completed", + timeout=args.stream_timeout, + ) + checks["pipeline completed"] = True + _expect_raw_input_ready(pty, args, description="normal prompt input ready") + checks["normal prompt input ready"] = True + pty.sendline(args.normal_followup_prompt) + pty.expect_any( + VSWITCH_MENTION_PATTERNS, + description="normal follow-up answered created VSwitch", + timeout=min(args.stream_timeout, 120.0), + ) + checks["normal follow-up answered created VSwitch"] = True + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_ask_waiting(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(args.ask_prompt) + pty.expect_any(ASK_PATTERNS, description="ask question visible", timeout=args.stream_timeout) + checks["ask question became visible"] = True + pty.sendline(_stack_creating_prompt(args.ask_answer, pty.run_dir, scenario)) + checks["ask answer sent"] = True + matched = pty.expect_any( + CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS, + description="pipeline continued after ask", + timeout=args.stream_timeout, + ) + checks["pipeline continued beyond ask"] = True + _finish_vswitch_pipeline_after_possible_selection( + pty, + args, + checks, + matched, + selection_check="candidate selection input sent after ask", + completion_check="pipeline completed after ask", + completion_description="pipeline completed after ask", + ) + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_image_initial(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + _submit_image_fixture(pty, "initial", caption=_stack_name_constraint(pty.run_dir, scenario)) + checks["initial image fixture pasted"] = True + pty.expect_any(PIPELINE_STARTED_PATTERNS, description="pipeline started", timeout=args.stream_timeout) + checks["pipeline started"] = True + _expect_candidate_selection(pty, args, description="candidate selection visible") + checks["candidate selection became visible"] = True + _select_default_candidate(pty, args) + checks["candidate selection input sent"] = True + pty.expect_any( + PIPELINE_COMPLETED_PATTERNS, + description="pipeline completed after image initial", + timeout=args.stream_timeout, + ) + checks["pipeline completed after image initial"] = True + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_image_ask_waiting_resume(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(args.ask_prompt) + pty.expect_any(ASK_PATTERNS, description="ask question visible before kill", timeout=args.stream_timeout) + checks["ask question became visible before kill"] = True + pty.terminate(force=True) + checks["first process killed"] = True + pty.spawn(extra_args=["--continue"]) + pty.expect_any(ASK_PATTERNS, description="ask question replayed", timeout=args.stream_timeout) + checks["ask question replayed"] = True + _expect_raw_input_ready(pty, args, description="ask image answer input ready after resume") + checks["ask image answer input ready after resume"] = True + _submit_image_fixture(pty, "ask-first-answer", caption=_stack_name_constraint(pty.run_dir, scenario)) + checks["ask first answer image fixture pasted after resume"] = True + if pty.expect_optional( + ASK_PATTERNS, + description="second ask question after image answer", + timeout=min(args.timeout, 30.0), + ): + _expect_raw_input_ready(pty, args, description="second ask image answer input ready") + _submit_image_fixture(pty, "ask-second-answer", caption=_stack_name_constraint(pty.run_dir, scenario)) + checks["ask second answer image fixture pasted"] = True + matched = pty.expect_any( + CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS, + description="pipeline continued after ask image resume", + timeout=args.stream_timeout, + ) + checks["pipeline continued beyond ask image after resume"] = True + _finish_vswitch_pipeline_after_possible_selection( + pty, + args, + checks, + matched, + selection_check="candidate selection input sent after ask image resume", + completion_check="pipeline completed after ask image resume", + completion_description="pipeline completed after ask image resume", + ) + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_image_selection_waiting_resume(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + _submit_image_fixture(pty, "initial", caption=_stack_name_constraint(pty.run_dir, scenario)) + checks["initial image fixture pasted"] = True + _expect_candidate_selection(pty, args, description="candidate selection visible before image resume kill") + checks["candidate selection became visible before kill"] = True + pty.terminate(force=True) + checks["first process killed"] = True + pty.spawn(extra_args=["--continue"]) + _expect_candidate_selection(pty, args, description="candidate selection replayed after image resume") + checks["candidate selection replayed after resume"] = True + _select_default_candidate(pty, args) + checks["candidate selection input sent after resume"] = True + pty.expect_any( + PIPELINE_COMPLETED_PATTERNS, + description="pipeline completed after image selection resume", + timeout=args.stream_timeout, + ) + checks["pipeline completed after image selection resume"] = True + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_image_normal_handoff(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario)) + pty.expect_any(PIPELINE_STARTED_PATTERNS, description="pipeline started", timeout=args.stream_timeout) + checks["pipeline started"] = True + _expect_candidate_selection(pty, args, description="candidate selection visible") + checks["candidate selection became visible"] = True + _select_default_candidate(pty, args) + checks["candidate selection input sent"] = True + pty.expect_any( + PIPELINE_FULLY_COMPLETED_PATTERNS, + description="pipeline fully completed", + timeout=args.stream_timeout, + ) + checks["pipeline completed"] = True + _expect_raw_input_ready(pty, args, description="normal prompt input ready") + checks["normal prompt input ready"] = True + _submit_image_fixture(pty, "normal-followup") + checks["normal follow-up image fixture pasted"] = True + pty.expect_any( + VSWITCH_MENTION_PATTERNS, + description="normal image follow-up answered created VSwitch", + timeout=min(args.stream_timeout, 120.0), + ) + checks["normal image follow-up answered created VSwitch"] = True + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_image_interrupt(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(args.initial_prompt) + pty.expect_any( + CANDIDATE_EVALUATION_PATTERNS, + description="candidate evaluation visible", + timeout=args.stream_timeout, + ) + checks["candidate evaluation reached"] = True + _expect_parallel_interrupt_ready(pty, args) + checks["parallel interrupt input ready"] = True + pty.send("\x1b", label="send-esc") + checks["esc sent"] = True + pty.expect_any( + REPL_INPUT_READY_PATTERNS, description="parallel interrupt text input ready", timeout=args.timeout + ) + checks["parallel interrupt text input ready"] = True + _submit_image_fixture(pty, "rollback-interrupt") + checks["rollback interrupt image fixture pasted"] = True + pty.expect_any( + POST_ROLLBACK_PROGRESS_PATTERNS, + description="post-rollback pipeline progress visible", + timeout=args.stream_timeout, + ) + checks["post-rollback pipeline progress visible"] = True + _expect_post_rollback_security_group_target(pty, args, checks) + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_selection_waiting_resume(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario)) + _expect_candidate_selection(pty, args, description="candidate selection visible") + checks["candidate selection became visible before kill"] = True + pty.terminate(force=True) + checks["first process killed"] = True + pty.spawn(extra_args=["--continue"]) + _expect_candidate_selection(pty, args, description="candidate selection replayed") + checks["candidate selection replayed"] = True + _select_default_candidate(pty, args) + checks["candidate selection input sent after resume"] = True + pty.expect_any( + PIPELINE_COMPLETED_PATTERNS, description="pipeline completed after resume", timeout=args.stream_timeout + ) + checks["pipeline completed after resume"] = True + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_ask_waiting_resume(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(args.ask_prompt) + pty.expect_any(ASK_PATTERNS, description="ask question visible before kill", timeout=args.stream_timeout) + checks["ask question became visible before kill"] = True + pty.terminate(force=True) + checks["first process killed"] = True + pty.spawn(extra_args=["--continue"]) + pty.expect_any(ASK_PATTERNS, description="ask question replayed", timeout=args.stream_timeout) + checks["ask question replayed"] = True + _expect_raw_input_ready(pty, args, description="ask answer input ready after resume") + checks["ask answer input ready after resume"] = True + pty.sendline(_stack_creating_prompt(args.ask_answer, pty.run_dir, scenario)) + checks["ask answer sent after resume"] = True + matched = pty.expect_any( + CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS, + description="pipeline continued after ask resume", + timeout=args.stream_timeout, + ) + checks["pipeline continued beyond ask after resume"] = True + _finish_vswitch_pipeline_after_possible_selection( + pty, + args, + checks, + matched, + selection_check="candidate selection input sent after ask resume", + completion_check="pipeline completed after ask resume", + completion_description="pipeline completed after ask resume", + ) + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_evaluate_resume(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario)) + pty.expect_any( + CANDIDATE_EVALUATION_PATTERNS, description="candidate evaluation visible", timeout=args.stream_timeout + ) + checks["candidate evaluation reached before kill"] = True + _expect_parallel_interrupt_ready(pty, args) + checks["parallel interrupt input ready before kill"] = True + pty.terminate(force=True) + checks["first process killed"] = True + pty.spawn(extra_args=["--continue"]) + pty.expect_any( + EVALUATE_CANDIDATES_HEADING_PATTERNS, + description="candidate evaluation replayed after resume", + timeout=args.stream_timeout, + ) + checks["candidate evaluation replayed after resume"] = True + _expect_raw_input_ready(pty, args, description="evaluate resume prompt input ready") + checks["evaluate resume prompt input ready"] = True + pty.sendline(args.evaluate_resume_continue_prompt) + checks["resume continue input sent"] = True + _expect_candidate_selection(pty, args, description="candidate selection visible after resume continue") + checks["candidate selection became visible after resume continue"] = True + _select_default_candidate(pty, args) + checks["candidate selection input sent after resume"] = True + pty.expect_any( + PIPELINE_COMPLETED_PATTERNS, + description="pipeline completed after evaluate resume", + timeout=args.stream_timeout, + ) + checks["pipeline completed after evaluate resume"] = True + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_selection_invalid_then_valid(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario)) + _expect_candidate_selection(pty, args, description="candidate selection visible") + checks["candidate selection became visible"] = True + pty.send(args.invalid_selection_prompt, label="select-invalid-candidate") + checks["invalid selection input sent"] = True + _select_default_candidate(pty, args) + checks["valid selection input sent after invalid input"] = True + pty.expect_any(PIPELINE_COMPLETED_PATTERNS, description="pipeline completed", timeout=args.stream_timeout) + checks["pipeline completed"] = True + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_rollback_step2(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(args.initial_prompt) + pty.expect_any( + ARCHITECTURE_PLANNING_PATTERNS, + description="architecture planning visible", + timeout=args.stream_timeout, + ) + checks["architecture planning reached"] = True + pty.send("\x1b", label="send-esc") + checks["esc sent"] = True + pty.expect_any(INTERRUPT_INPUT_PATTERNS, description="interrupt input visible", timeout=args.timeout) + checks["interrupt input visible"] = True + _expect_raw_input_ready(pty, args, description="interrupt prompt input ready") + checks["interrupt prompt input ready"] = True + pty.sendline(args.rollback_prompt) + checks["rollback prompt sent"] = True + pty.expect_any( + POST_ROLLBACK_PROGRESS_PATTERNS, + description="post-rollback pipeline progress visible", + timeout=args.stream_timeout, + ) + checks["post-rollback pipeline progress visible"] = True + _expect_post_rollback_security_group_target(pty, args, checks) + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_rollback_step3(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(args.initial_prompt) + pty.expect_any( + CANDIDATE_EVALUATION_PATTERNS, + description="candidate evaluation visible", + timeout=args.stream_timeout, + ) + checks["candidate evaluation reached"] = True + _expect_parallel_interrupt_ready(pty, args) + checks["parallel interrupt input ready"] = True + pty.send("\x1b", label="send-esc") + checks["esc sent"] = True + pty.expect_any( + REPL_INPUT_READY_PATTERNS, description="parallel interrupt text input ready", timeout=args.timeout + ) + checks["parallel interrupt text input ready"] = True + pty.sendline(args.rollback_prompt) + checks["rollback prompt sent"] = True + pty.expect_any( + POST_ROLLBACK_PROGRESS_PATTERNS, + description="post-rollback pipeline progress visible", + timeout=args.stream_timeout, + ) + checks["post-rollback pipeline progress visible"] = True + _expect_post_rollback_security_group_target(pty, args, checks) + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_rollback_step4_selection(args: argparse.Namespace, scenario: str) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + pty.sendline(args.initial_prompt) + _expect_candidate_selection(pty, args, description="candidate selection visible") + checks["candidate selection reached"] = True + _expect_raw_input_ready(pty, args, description="candidate selection input ready") + checks["candidate selection input ready"] = True + pty.send("\x1b", label="send-esc") + checks["esc sent"] = True + _expect_raw_input_ready(pty, args, description="candidate selection interrupt text input ready") + checks["candidate selection interrupt text input ready"] = True + pty.sendline(args.rollback_prompt) + checks["rollback prompt sent"] = True + pty.expect_any( + POST_ROLLBACK_PROGRESS_PATTERNS, + description="post-rollback pipeline progress visible", + timeout=args.stream_timeout, + ) + checks["post-rollback pipeline progress visible"] = True + _expect_post_rollback_security_group_target(pty, args, checks) + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +def run_rollback_step5_cleanup(args: argparse.Namespace, scenario: str) -> int: + return _run_rollback_step5_cleanup(args, scenario, kill_during_cleanup=False) + + +def run_rollback_step5_cleanup_recovery(args: argparse.Namespace, scenario: str) -> int: + return _run_rollback_step5_cleanup(args, scenario, kill_during_cleanup=True) + + +def _run_rollback_step5_cleanup( + args: argparse.Namespace, + scenario: str, + *, + kill_during_cleanup: bool, +) -> int: + def callback(pty: ReplPty, checks: dict[str, bool]) -> None: + _expect_initial_prompt(pty, args) + _ensure_cleanup_network_target(args, pty.run_dir) + checks["cleanup network target prepared"] = True + pty.sendline(_cleanup_pipeline_prompt(args, pty.run_dir)) + _expect_candidate_selection(pty, args, description="initial candidate selection visible") + checks["initial reached step4 selection"] = True + + _select_default_candidate(pty, args) + checks["initial candidate selected"] = True + pty.expect_any( + CREATE_STACK_STARTED_PATTERNS, + description="first stack create started", + timeout=args.stream_timeout, + ) + first_stack_id = _wait_for_latest_observed_stack_id(pty, exclude=set(), timeout=args.stream_timeout) + pty.cleanup_first_stack_id = first_stack_id + checks["first rollback stack observed before rollback"] = bool(first_stack_id) + + pty.send("\x1b", label="send-esc") + checks["esc sent during deploying"] = True + _expect_raw_input_ready(pty, args, description="deploying interrupt input ready") + checks["deploying interrupt input ready"] = True + pty.sendline(_cleanup_rollback_prompt(args, pty.run_dir)) + checks["rollback prompt sent"] = True + _expect_candidate_selection(pty, args, description="post-rollback candidate selection visible") + checks["post-rollback candidate selection visible"] = True + + cleanup_stack_ids = _wait_for_cleanup_target_stack_ids(pty, exclude=set(), timeout=args.timeout) + checks["rollback cleanup ledger includes first stack"] = first_stack_id in cleanup_stack_ids + checks["rollback cleanup target stacks observed"] = bool(cleanup_stack_ids) + + _select_default_candidate(pty, args) + checks["post-rollback candidate selected"] = True + pty.expect_any( + PIPELINE_FULLY_COMPLETED_PATTERNS, + description="pipeline completed after second deployment", + timeout=args.stream_timeout, + ) + checks["pipeline completed after second deployment"] = True + + second_stack_id = _latest_observed_stack_id(pty, exclude=set(cleanup_stack_ids) | {first_stack_id}) + pty.cleanup_second_stack_id = second_stack_id or "" + checks["second stack created after rollback"] = bool(second_stack_id) + checks["second stack differs from first rollback stack"] = ( + bool(second_stack_id) and second_stack_id != first_stack_id + ) + + cleanup_stack_ids = _cleanup_target_stack_ids( + pty, + exclude={stack_id for stack_id in [second_stack_id] if stack_id}, + ) + checks["rollback cleanup ledger includes first stack"] = first_stack_id in cleanup_stack_ids + checks["rollback cleanup target stacks observed"] = bool(cleanup_stack_ids) + checks["cleanup snapshot does not target second stack"] = ( + bool(second_stack_id) and _cleanup_resource_for_stack(pty, second_stack_id) is None + ) + + pty.sendline(args.normal_followup_prompt) + if kill_during_cleanup: + pty.expect_any( + CLEANUP_STARTED_PATTERNS, + description="cleanup started before kill", + timeout=args.stream_timeout, + ) + checks["cleanup started before kill"] = True + pty.terminate(force=True) + checks["cleanup process killed"] = True + pty.spawn(extra_args=["--continue"]) + pty.expect_any( + CLEANUP_RESUME_SUMMARY_PATTERNS, + description="cleanup resume summary", + timeout=args.stream_timeout, + ) + if _cleanup_resource_completed(_cleanup_resource_for_stack(pty, first_stack_id)): + checks["cleanup already completed after restart"] = True + else: + _expect_raw_input_ready(pty, args, description="cleanup resume prompt input ready") + pty.sendline(args.cleanup_continue_prompt) + checks["cleanup continue prompt sent after restart"] = True + else: + pty.expect_any(CLEANUP_STARTED_PATTERNS, description="cleanup started", timeout=args.stream_timeout) + checks["cleanup started"] = True + + _wait_for_cleanup_completed_and_ready(pty, args, first_stack_id) + checks["first rollback stack cleanup completed in ledger"] = _cleanup_resource_completed( + _cleanup_resource_for_stack(pty, first_stack_id) + ) + checks["rollback cleanup stacks completed in ledger"] = bool(cleanup_stack_ids) and all( + _cleanup_resource_completed(_cleanup_resource_for_stack(pty, stack_id)) for stack_id in cleanup_stack_ids + ) + pty.sendline("/exit") + + return _run_with_pty(args, scenario, callback) + + +_SCENARIOS: dict[str, Callable[[argparse.Namespace, str], int]] = { + "scenario1": run_scenario1, + "ask-waiting": run_ask_waiting, + "ask-waiting-resume": run_ask_waiting_resume, + "image-initial": run_image_initial, + "image-ask-waiting-resume": run_image_ask_waiting_resume, + "image-selection-waiting-resume": run_image_selection_waiting_resume, + "image-normal-handoff": run_image_normal_handoff, + "image-interrupt": run_image_interrupt, + "evaluate-resume": run_evaluate_resume, + "selection-invalid-then-valid": run_selection_invalid_then_valid, + "selection-waiting-resume": run_selection_waiting_resume, + "rollback-step2": run_rollback_step2, + "rollback-step3": run_rollback_step3, + "rollback-step4-selection": run_rollback_step4_selection, + "rollback-step5-cleanup": run_rollback_step5_cleanup, + "rollback-step5-cleanup-recovery": run_rollback_step5_cleanup_recovery, +} +_REAL_CLOUD_SCENARIOS = frozenset(_SCENARIOS) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/iac_code/a2a/app.py b/src/iac_code/a2a/app.py index 572f3b49..096b5471 100644 --- a/src/iac_code/a2a/app.py +++ b/src/iac_code/a2a/app.py @@ -6,6 +6,7 @@ import hashlib import hmac import json +import logging import os from contextlib import asynccontextmanager, suppress from email.utils import formatdate @@ -25,8 +26,13 @@ from starlette.routing import BaseRoute, Route from iac_code.a2a.agent_card import agent_card_to_client_dict +from iac_code.a2a.jsonrpc_passthrough import ( + install_jsonrpc_error_data_passthrough, + install_v03_jsonrpc_error_data_passthrough, +) from iac_code.i18n import _ +logger = logging.getLogger(__name__) _V03_JSONRPC_METHODS = frozenset( { "message/send", @@ -273,7 +279,9 @@ async def get_pipeline_state(request: Request) -> JSONResponse: Route(AGENT_CARD_WELL_KNOWN_PATH, get_agent_card, methods=["GET"]), Route("/iac-code/pipeline/state", get_pipeline_state, methods=["GET"]), ] + install_jsonrpc_error_data_passthrough() jsonrpc_endpoint = create_jsonrpc_routes(components.handler, rpc_url="/", enable_v0_3_compat=True)[0].endpoint + install_v03_jsonrpc_error_data_passthrough(jsonrpc_endpoint) async def handle_jsonrpc(request: Request) -> Response: await normalize_v03_jsonrpc_version(request) diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 7a4821c3..f50710c9 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -2,10 +2,11 @@ import asyncio import contextlib +import json import logging import os import uuid -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from pathlib import Path from typing import Any, TypeAlias @@ -13,15 +14,25 @@ from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.types import Message, Role, Task, TaskState, TaskStatus, TaskStatusUpdateEvent +from a2a.utils.errors import InvalidParamsError from google.protobuf.json_format import MessageToDict from iac_code.a2a.events import make_text_part, publish_stream_event from iac_code.a2a.exposure import normalize_a2a_exposure_types from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics -from iac_code.a2a.parts import allowed_cwd_roots, is_relative_to, parts_to_prompt, resolve_workspace_path +from iac_code.a2a.parts import ( + allowed_cwd_roots, + is_relative_to, + parts_to_pipeline_input, + parts_to_prompt, + resolve_workspace_path, +) +from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, recoverable_task_id_from_sidecar +from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_paths import existing_a2a_pipeline_dir_for_session -from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore +from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events +from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher from iac_code.a2a.task_store import A2ATaskStore from iac_code.a2a.types import ( TASK_STATE_CANCELED, @@ -30,17 +41,38 @@ TASK_STATE_WORKING, ) from iac_code.agent.message import Message as AgentMessage +from iac_code.config import get_active_provider_key, get_provider_config, load_credentials from iac_code.i18n import _ from iac_code.pipeline.config import RunMode, get_run_mode +from iac_code.pipeline.constants import ( + PIPELINE_EVENT_CLEANUP_COMPLETED, + PIPELINE_EVENT_CLEANUP_FAILED, + PIPELINE_EVENT_CLEANUP_PROGRESS, + PIPELINE_EVENT_CLEANUP_STARTED, +) +from iac_code.pipeline.engine.cleanup import ( + CLEANUP_PROMPT_METADATA_TYPE, + CleanupLedger, + CleanupObserver, + cleanup_prompt_ledger_path, + create_cleanup_prompt_message, + is_active_cleanup_prompt_message, + mark_cleanup_prompt_message_completed, +) +from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime +from iac_code.services.capabilities.multimodal import is_model_multimodal from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential, use_aliyun_credential from iac_code.services.session_storage import SessionStorage from iac_code.services.telemetry import use_session_id, use_user_id +from iac_code.types.stream_events import TextDeltaEvent +from iac_code.utils.file_security import atomic_write_text, ensure_private_dir, ensure_private_file from iac_code.utils.public_errors import public_exception_summary, sanitize_public_text logger = logging.getLogger(__name__) _CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1 _ERROR_TEXT_MAX_CHARS = 1000 +_DEFERRED_CLEANUP_PROMPTS_FILENAME = "cleanup-deferred-prompts.json" def _format_exception(exc: BaseException) -> str: @@ -58,6 +90,671 @@ def _is_relative_to(path: Path, root: Path) -> bool: return is_relative_to(path, root) +def _cleanup_prompt_from_handoff(handoff: dict[str, Any]) -> str | None: + data = handoff.get("data") + if not isinstance(data, dict): + return None + cleanup = data.get("cleanup") + if not isinstance(cleanup, dict): + return None + prompt = cleanup.get("prompt") + return prompt if isinstance(prompt, str) and prompt else None + + +def _cleanup_ledger_path_from_handoff(handoff: dict[str, Any]) -> str | None: + data = handoff.get("data") + if not isinstance(data, dict): + return None + cleanup = data.get("cleanup") + if not isinstance(cleanup, dict): + return None + path = cleanup.get("ledgerPath") or cleanup.get("ledger_path") + return path if isinstance(path, str) and path else None + + +def _cleanup_payload_from_private_ledger_or_unavailable( + *, + ledger_path: Path, +) -> dict[str, Any]: + ledger = CleanupLedger(ledger_path) + try: + ledger_exists = ledger_path.exists() + except OSError: + ledger_exists = False + if not ledger_exists or ledger.load_failed(): + return { + "status": "unavailable", + "statusMessage": _("Cleanup state unavailable. Inspect the session file and cloud resources manually."), + } + prompt = ledger.build_pending_prompt() + if prompt is None: + return {"status": "completed", "resourceCount": 0} + return { + "status": "pending", + "resourceCount": len(prompt.resources), + "statusMessage": prompt.status_message, + "prompt": prompt.prompt, + "ledgerPath": str(ledger_path), + } + + +def _session_has_user_message( + messages: list[AgentMessage], + *, + content: str, + metadata_type: str | None = None, +) -> bool: + for message in messages: + if getattr(message, "role", None) != "user" or getattr(message, "content", None) != content: + continue + if metadata_type is None: + return True + metadata = getattr(message, "metadata", None) + if isinstance(metadata, dict) and metadata.get("type") == metadata_type: + return True + return False + + +def _messages_have_cleanup_prompt(messages: list[Any]) -> bool: + return any(_message_is_cleanup_prompt(message) for message in messages) + + +def _messages_have_active_cleanup_prompt(messages: list[Any]) -> bool: + return any(is_active_cleanup_prompt_message(message) for message in messages) + + +def _session_has_active_cleanup_prompt_content(messages: list[AgentMessage], *, content: str) -> bool: + for message in messages: + if getattr(message, "role", None) != "user" or getattr(message, "content", None) != content: + continue + if is_active_cleanup_prompt_message(message): + return True + return False + + +def _message_is_cleanup_prompt(message: Any) -> bool: + metadata = getattr(message, "metadata", None) + return isinstance(metadata, dict) and metadata.get("type") == CLEANUP_PROMPT_METADATA_TYPE + + +def _cleanup_ledger_for_a2a_normal_chat(*, cwd: str, session_id: str) -> CleanupLedger | None: + try: + messages = SessionStorage().load(cwd, session_id) + except Exception: + logger.warning("Failed to inspect A2A session cleanup prompt", exc_info=True) + messages = [] + has_active_cleanup_prompt = False + for message in messages: + if not is_active_cleanup_prompt_message(message): + continue + has_active_cleanup_prompt = True + ledger_path = cleanup_prompt_ledger_path(message) + if ledger_path: + return CleanupLedger(ledger_path) + try: + path = SessionStorage().session_dir(cwd, session_id) / "pipeline" / "cleanup.yaml" + except Exception: + logger.warning("Failed to locate A2A pipeline cleanup ledger", exc_info=True) + return None + if not path.exists(): + return None + ledger = CleanupLedger(path) + if has_active_cleanup_prompt: + return ledger + if ledger.load_failed(): + return None + return ledger if ledger.pending_resources() else None + + +def _default_cleanup_ledger_path(*, cwd: str, session_id: str) -> Path: + return SessionStorage().session_dir(cwd, session_id) / "pipeline" / "cleanup.yaml" + + +def _ensure_cleanup_prompt_in_session(*, cwd: str, session_id: str, ledger: CleanupLedger, runtime: Any) -> None: + cleanup_prompt = ledger.build_pending_prompt() + if cleanup_prompt is None: + return + message = create_cleanup_prompt_message( + cleanup_prompt.prompt, + cleanup_ledger_path=ledger.path, + cleanup_status="pending", + ) + session_storage = SessionStorage() + messages = session_storage.load(cwd, session_id) + if _session_has_active_cleanup_prompt_content( + messages, + content=cleanup_prompt.prompt, + ): + _ensure_cleanup_prompt_in_runtime(runtime=runtime, message=message) + return + session_storage.append(cwd, session_id, message) + ledger.record_prompt_queued(cleanup_prompt, ui_surface="a2a") + _ensure_cleanup_prompt_in_runtime(runtime=runtime, message=message) + + +def _ensure_cleanup_prompt_in_runtime(*, runtime: Any, message: AgentMessage) -> None: + context_manager = getattr(getattr(runtime, "agent_loop", None), "context_manager", None) + remover = getattr(context_manager, "remove_cleanup_prompt_messages", None) + add_raw_message = getattr(context_manager, "add_raw_message", None) + if not callable(add_raw_message): + return + if callable(remover): + try: + remover() + except Exception: + logger.warning("Failed to replace A2A cleanup prompt in runtime context", exc_info=True) + try: + add_raw_message(message.to_dict()) + except Exception: + logger.warning("Failed to inject A2A cleanup prompt into runtime context", exc_info=True) + + +def _runtime_has_cleanup_prompt(runtime: Any) -> bool: + context_manager = getattr(getattr(runtime, "agent_loop", None), "context_manager", None) + get_messages = getattr(context_manager, "get_messages", None) + if not callable(get_messages): + return False + try: + messages = get_messages() + except Exception: + return False + return isinstance(messages, list) and _messages_have_active_cleanup_prompt(messages) + + +def _session_has_cleanup_prompt(*, cwd: str, session_id: str) -> bool: + try: + messages = SessionStorage().load(cwd, session_id) + except Exception: + logger.warning("Failed to inspect A2A session cleanup prompt", exc_info=True) + return False + return _messages_have_active_cleanup_prompt(messages) + + +def _a2a_cleanup_prompt_exists(*, runtime: Any, cwd: str, session_id: str) -> bool: + return _runtime_has_cleanup_prompt(runtime) or _session_has_cleanup_prompt(cwd=cwd, session_id=session_id) + + +def _a2a_cleanup_ledger_unavailable( + ledger: CleanupLedger | None, + *, + runtime: Any, + cwd: str, + session_id: str, +) -> bool: + if not _a2a_cleanup_prompt_exists(runtime=runtime, cwd=cwd, session_id=session_id): + return False + if ledger is None: + return True + try: + if not ledger.path.exists(): + return True + except Exception: + return True + return ledger.load_failed() + + +def _a2a_deferred_cleanup_prompts_path(*, cwd: str, session_id: str) -> Path: + return SessionStorage().session_dir(cwd, session_id) / "a2a" / _DEFERRED_CLEANUP_PROMPTS_FILENAME + + +def _read_a2a_deferred_cleanup_prompts(*, cwd: str, session_id: str) -> tuple[list[str], bool]: + path = _a2a_deferred_cleanup_prompts_path(cwd=cwd, session_id=session_id) + if not path.exists(): + return [], False + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("Failed to load deferred A2A cleanup prompts", exc_info=True) + return [], True + raw_prompts = data.get("prompts") if isinstance(data, dict) else None + if not isinstance(raw_prompts, list): + raw_prompt = data.get("prompt") if isinstance(data, dict) else None + raw_prompts = [raw_prompt] if isinstance(raw_prompt, str) else [] + return [prompt for prompt in raw_prompts if isinstance(prompt, str) and prompt.strip()], False + + +def _load_a2a_deferred_cleanup_prompts(*, cwd: str, session_id: str) -> list[str]: + prompts, _load_failed = _read_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id) + return prompts + + +def _save_a2a_deferred_cleanup_prompts(*, cwd: str, session_id: str, prompts: list[str]) -> None: + path = _a2a_deferred_cleanup_prompts_path(cwd=cwd, session_id=session_id) + if not prompts: + _clear_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id) + return + try: + ensure_private_dir(path.parent) + atomic_write_text( + path, + json.dumps({"prompts": prompts}, ensure_ascii=False, sort_keys=True), + ) + ensure_private_file(path) + except OSError: + logger.warning("Failed to persist deferred A2A cleanup prompt", exc_info=True) + + +def _append_a2a_deferred_cleanup_prompt(*, cwd: str, session_id: str, prompt: str) -> bool: + prompt = prompt.strip() + if not prompt: + return True + prompts, load_failed = _read_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id) + if load_failed: + return False + if prompts and _is_cleanup_continue_prompt(prompt): + prompts = [prompts[-1]] + else: + prompts = [prompt] + _save_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id, prompts=prompts) + return True + + +def _clear_a2a_deferred_cleanup_prompts(*, cwd: str, session_id: str) -> None: + path = _a2a_deferred_cleanup_prompts_path(cwd=cwd, session_id=session_id) + try: + path.unlink() + except FileNotFoundError: + return + except OSError: + logger.warning("Failed to clear deferred A2A cleanup prompts", exc_info=True) + + +def _a2a_prompts_after_cleanup(*, cwd: str, session_id: str, prompt: str) -> tuple[list[str], bool] | None: + deferred_prompts, load_failed = _read_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id) + if load_failed: + return None + if not deferred_prompts: + return [prompt], False + if prompt.strip(): + if not _append_a2a_deferred_cleanup_prompt(cwd=cwd, session_id=session_id, prompt=prompt): + return None + deferred_prompts, load_failed = _read_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id) + if load_failed: + return None + return deferred_prompts, True + + +def _is_cleanup_continue_prompt(prompt: str) -> bool: + normalized = prompt.strip().lower() + return normalized in {"continue", "继续"} + + +def _a2a_pipeline_state_for_session( + *, + cwd: str, + session_id: str, +) -> tuple[A2APipelineSnapshotStore, A2APipelineJournal, dict[str, Any], list[dict[str, Any]] | None] | None: + try: + pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) + snapshot_store = A2APipelineSnapshotStore(pipeline_dir) + journal = A2APipelineJournal(pipeline_dir) + snapshot = snapshot_store.load() + except Exception: + logger.warning("Failed to load A2A pipeline snapshot", exc_info=True) + return None + journal_events: list[dict[str, Any]] | None = None + if not isinstance(snapshot, dict): + try: + journal_events = journal.read_all_repairing_tail() + except Exception: + logger.warning("Failed to rebuild A2A pipeline snapshot from journal", exc_info=True) + return None + if not journal_events: + return None + snapshot = reduce_pipeline_events(journal_events) + return snapshot_store, journal, snapshot, journal_events + + +def _prune_completed_cleanup_prompt_from_runtime(runtime: Any, ledger: CleanupLedger | None) -> None: + if ledger is None and _runtime_has_cleanup_prompt(runtime): + logger.warning("Keeping A2A cleanup prompt because cleanup ledger is unavailable") + return + if ledger is not None and ledger.load_failed(): + logger.warning("Keeping A2A cleanup prompt because cleanup ledger is unreadable") + return + if ledger is not None and not ledger.path.exists() and _runtime_has_cleanup_prompt(runtime): + logger.warning("Keeping A2A cleanup prompt because cleanup ledger is unavailable") + return + if ledger is not None and ledger.pending_resources(): + return + context_manager = getattr(getattr(runtime, "agent_loop", None), "context_manager", None) + remover = getattr(context_manager, "remove_cleanup_prompt_messages", None) + if not callable(remover): + return + try: + remover() + except Exception: + logger.warning("Failed to remove completed A2A cleanup prompt from context", exc_info=True) + + +def _mark_completed_cleanup_prompts( + *, + runtime: Any, + cwd: str, + session_id: str, + ledger: CleanupLedger, +) -> None: + ledger_path = getattr(ledger, "path", None) + context_manager = getattr(getattr(runtime, "agent_loop", None), "context_manager", None) + get_messages = getattr(context_manager, "get_messages", None) + if callable(get_messages): + try: + messages = get_messages() + except Exception: + messages = [] + if isinstance(messages, list): + for message in messages: + mark_cleanup_prompt_message_completed(message, cleanup_ledger_path=ledger_path) + + session_storage = SessionStorage() + try: + messages = session_storage.load(cwd, session_id) + except Exception: + logger.warning("Failed to load A2A session while marking cleanup prompt completed", exc_info=True) + return + changed = False + for message in messages: + changed = mark_cleanup_prompt_message_completed(message, cleanup_ledger_path=ledger_path) or changed + if not changed: + return + try: + session_storage.save(cwd, session_id, messages) + except Exception: + logger.warning("Failed to mark A2A cleanup prompt completed in session", exc_info=True) + + +def _cleanup_publisher_for_a2a_normal_chat( + *, + event_queue: EventQueue, + cwd: str, + session_id: str, + task_id: str, + context_id: str, + artifact_store: Any | None, + exposure_types: Any, +) -> PipelineA2AEventPublisher | None: + state = _a2a_pipeline_state_for_session(cwd=cwd, session_id=session_id) + if state is None: + return None + snapshot_store, journal, snapshot, journal_events = state + + translator = PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id=_string_value(snapshot.get("pipelineRunId")) or context_id, + task_id=_string_value(snapshot.get("taskId")) or task_id, + context_id=_string_value(snapshot.get("contextId")) or context_id, + pipeline_name=_string_value(snapshot.get("pipelineName")) or "pipeline", + ) + ) + try: + if journal_events is None: + journal_events = journal.read_all_repairing_tail() + translator.hydrate_from_events(journal_events) + except Exception: + logger.warning("Failed to hydrate A2A cleanup event translator", exc_info=True) + return PipelineA2AEventPublisher( + event_queue, + translator, + journal, + snapshot_store, + artifact_store=artifact_store, + exposure_types=exposure_types, + delivery_task_id=task_id, + delivery_context_id=context_id, + ) + + +async def _observe_cleanup_stream( + events: AsyncIterator[Any], + ledger: CleanupLedger, + *, + publisher: PipelineA2AEventPublisher | None = None, +) -> AsyncIterator[Any]: + if ledger.load_failed(): + async for event in events: + yield event + return + observer = CleanupObserver(ledger) + previous = ( + _published_cleanup_resource_states(publisher, ledger) + if publisher is not None + else _cleanup_resource_states(ledger) + ) + if publisher is not None: + previous = await _publish_cleanup_resource_changes(publisher, ledger, previous) + async for event in events: + observer.observe(event) + if publisher is not None: + previous = await _publish_cleanup_resource_changes(publisher, ledger, previous) + yield event + + +def _cleanup_resource_state(resource: Any) -> tuple[Any, ...]: + return ( + getattr(resource, "cleanup_status", None), + getattr(resource, "progress_status", None), + getattr(resource, "progress_percentage", None), + getattr(resource, "cleanup_tool_use_id", None), + getattr(resource, "last_error", None), + ) + + +def _cleanup_resource_states(ledger: CleanupLedger) -> dict[str, tuple[Any, ...]]: + return {resource.key: _cleanup_resource_state(resource) for resource in ledger.cleanup_resources()} + + +def _published_cleanup_resource_states( + publisher: PipelineA2AEventPublisher, + ledger: CleanupLedger, +) -> dict[str, tuple[Any, ...]]: + snapshot_store = getattr(publisher, "snapshot_store", None) + load = getattr(snapshot_store, "load", None) + if not callable(load): + return {} + try: + snapshot = load() + except Exception: + logger.warning("Failed to load A2A cleanup snapshot state for catch-up", exc_info=True) + return {} + if not isinstance(snapshot, dict): + return {} + cleanup = snapshot.get("cleanup") + if not isinstance(cleanup, dict): + return {} + snapshot_resources = [item for item in cleanup.get("resources", []) if isinstance(item, dict)] + states: dict[str, tuple[Any, ...]] = {} + for resource in ledger.cleanup_resources(): + match = _matching_snapshot_cleanup_resource(resource, snapshot_resources) + if match is not None: + states[resource.key] = _snapshot_cleanup_resource_state(match) + return states + + +def _matching_snapshot_cleanup_resource(resource: Any, candidates: list[dict[str, Any]]) -> dict[str, Any] | None: + for candidate in candidates: + if candidate.get("resourceId") != getattr(resource, "resource_id", None): + continue + if not _optional_cleanup_field_matches(candidate.get("regionId"), getattr(resource, "region_id", None)): + continue + if not _optional_cleanup_field_matches(candidate.get("provider"), getattr(resource, "provider", None)): + continue + resource_type = candidate.get("resourceType") or candidate.get("resource_type") + if not _optional_cleanup_field_matches(resource_type, getattr(resource, "resource_type", None)): + continue + return candidate + return None + + +def _optional_cleanup_field_matches(snapshot_value: Any, ledger_value: Any) -> bool: + snapshot_text = snapshot_value if isinstance(snapshot_value, str) and snapshot_value else None + ledger_text = ledger_value if isinstance(ledger_value, str) and ledger_value else None + return snapshot_text is None or ledger_text is None or snapshot_text == ledger_text + + +def _snapshot_cleanup_resource_state(resource: dict[str, Any]) -> tuple[Any, ...]: + return ( + resource.get("cleanupStatus") or resource.get("cleanup_status") or resource.get("status"), + resource.get("progressStatus") or resource.get("stackStatus"), + resource.get("progressPercentage"), + resource.get("cleanupToolUseId") or resource.get("cleanup_tool_use_id"), + resource.get("lastError"), + ) + + +async def _publish_cleanup_resource_changes( + publisher: PipelineA2AEventPublisher, + ledger: CleanupLedger, + previous: dict[str, tuple[Any, ...]], +) -> dict[str, tuple[Any, ...]]: + resources = ledger.cleanup_resources() + current = {resource.key: _cleanup_resource_state(resource) for resource in resources} + next_previous = dict(previous) + for resource in resources: + state = current.get(resource.key) + if state is None or previous.get(resource.key) == state: + continue + event_type = _cleanup_event_type_for_status(resource.cleanup_status) + if event_type is None: + continue + try: + published = await publisher.publish_manual( + event_type, + "cleanup", + status="working", + data=_cleanup_resource_event_data(resource, resource_count=len(resources)), + require_durable_metadata=True, + ) + except Exception: + logger.warning("Failed to publish A2A cleanup progress event", exc_info=True) + continue + if published is not None: + next_previous[resource.key] = state + return next_previous + + +def _cleanup_event_type_for_status(status: str) -> str | None: + if status == "started": + return PIPELINE_EVENT_CLEANUP_STARTED + if status == "in_progress": + return PIPELINE_EVENT_CLEANUP_PROGRESS + if status == "completed": + return PIPELINE_EVENT_CLEANUP_COMPLETED + if status == "failed": + return PIPELINE_EVENT_CLEANUP_FAILED + return None + + +def _cleanup_resource_event_data(resource: Any, *, resource_count: int) -> dict[str, Any]: + data = { + "status": getattr(resource, "cleanup_status", None), + "resourceCount": resource_count, + "provider": getattr(resource, "provider", None), + "resourceType": getattr(resource, "resource_type", None), + "resourceId": getattr(resource, "resource_id", None), + "resourceName": getattr(resource, "resource_name", None), + "regionId": getattr(resource, "region_id", None), + "sourceStepId": getattr(resource, "source_step_id", None), + "cleanupStatus": getattr(resource, "cleanup_status", None), + "cleanupToolUseId": getattr(resource, "cleanup_tool_use_id", None), + "progressStatus": getattr(resource, "progress_status", None), + "progressPercentage": getattr(resource, "progress_percentage", None), + "stackStatus": getattr(resource, "progress_status", None), + "lastError": _public_cleanup_error(getattr(resource, "last_error", None)), + } + return {key: value for key, value in data.items() if value is not None} + + +def _public_cleanup_error(value: Any) -> str | None: + if not value: + return None + text = sanitize_public_text(str(value)) + return text[:_ERROR_TEXT_MAX_CHARS] + "..." if len(text) > _ERROR_TEXT_MAX_CHARS else text + + +async def _stream_a2a_normal_events( + *, + runtime: Any, + prompt: str, + cleanup_ledger: CleanupLedger | None, + cleanup_publisher: PipelineA2AEventPublisher | None, + cwd: str, + session_id: str, +) -> AsyncIterator[Any]: + if _a2a_cleanup_ledger_unavailable(cleanup_ledger, runtime=runtime, cwd=cwd, session_id=session_id): + if not _append_a2a_deferred_cleanup_prompt(cwd=cwd, session_id=session_id, prompt=prompt): + yield TextDeltaEvent( + text=_("Rollback cleanup deferred prompt state is unavailable. Please repair it before continuing.") + ) + return + yield TextDeltaEvent( + text=_("Rollback cleanup state is unavailable. Please repair the cleanup ledger before continuing.") + ) + return + + if cleanup_ledger is not None and cleanup_ledger.load_failed(): + if _runtime_has_cleanup_prompt(runtime) or _session_has_cleanup_prompt(cwd=cwd, session_id=session_id): + if not _append_a2a_deferred_cleanup_prompt(cwd=cwd, session_id=session_id, prompt=prompt): + yield TextDeltaEvent( + text=_("Rollback cleanup deferred prompt state is unavailable. Please repair it before continuing.") + ) + return + yield TextDeltaEvent( + text=_("Rollback cleanup state is unavailable. Please repair the cleanup ledger before continuing.") + ) + return + + run_cleanup_continuation = ( + cleanup_ledger is not None + and not cleanup_ledger.load_failed() + and bool(cleanup_ledger.pending_resources()) + and callable(getattr(runtime.agent_loop, "continue_streaming", None)) + ) + if run_cleanup_continuation and cleanup_ledger is not None: + _ensure_cleanup_prompt_in_session(cwd=cwd, session_id=session_id, ledger=cleanup_ledger, runtime=runtime) + cleanup_stream = _observe_cleanup_stream( + runtime.agent_loop.continue_streaming(), + cleanup_ledger, + publisher=cleanup_publisher, + ) + async for event in cleanup_stream: + yield event + if cleanup_ledger.pending_resources(): + if not _append_a2a_deferred_cleanup_prompt(cwd=cwd, session_id=session_id, prompt=prompt): + yield TextDeltaEvent( + text=_("Rollback cleanup deferred prompt state is unavailable. Please repair it before continuing.") + ) + return + yield TextDeltaEvent( + text=_("Rollback cleanup is still in progress. Please continue after cleanup completes.") + ) + return + _mark_completed_cleanup_prompts(runtime=runtime, cwd=cwd, session_id=session_id, ledger=cleanup_ledger) + _prune_completed_cleanup_prompt_from_runtime(runtime, cleanup_ledger) + + prompts_after_cleanup = _a2a_prompts_after_cleanup(cwd=cwd, session_id=session_id, prompt=prompt) + if prompts_after_cleanup is None: + yield TextDeltaEvent( + text=_("Rollback cleanup deferred prompt state is unavailable. Please repair it before continuing.") + ) + return + prompts_to_run, has_deferred_prompts = prompts_after_cleanup + for prompt_to_run in prompts_to_run: + prompt_stream = runtime.agent_loop.run_streaming(prompt_to_run) + if cleanup_ledger is not None: + prompt_stream = _observe_cleanup_stream(prompt_stream, cleanup_ledger, publisher=cleanup_publisher) + async for event in prompt_stream: + yield event + if cleanup_ledger is not None and not cleanup_ledger.load_failed() and not cleanup_ledger.pending_resources(): + _mark_completed_cleanup_prompts(runtime=runtime, cwd=cwd, session_id=session_id, ledger=cleanup_ledger) + _prune_completed_cleanup_prompt_from_runtime(runtime, cleanup_ledger) + if has_deferred_prompts: + _clear_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id) + + +def _string_value(value: Any) -> str: + return value if isinstance(value, str) and value else "" + + class IacCodeA2AExecutor(AgentExecutor): def __init__( self, @@ -85,6 +782,15 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non task_id = requested_task_id or "task-" + uuid.uuid4().hex[:12] context_id = context.context_id or "ctx-" + uuid.uuid4().hex[:12] task = None + initial_task_published = False + + async def publish_initial_task_if_missing() -> None: + nonlocal initial_task_published + if initial_task_published or isinstance(getattr(context, "current_task", None), Task): + return + await self._publish_initial_task(event_queue, task_id=task_id, context_id=context_id, context=context) + initial_task_published = True + try: metadata = getattr(context, "metadata", None) or getattr( getattr(context, "message", None), "metadata", None @@ -94,8 +800,23 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non metadata_model = self._resolve_model(metadata) model = metadata_model or self._model aliyun_credential = self._resolve_aliyun_credential(metadata) - prompt = self._prompt_from_context(context, cwd=cwd) pipeline_mode = get_run_mode() == RunMode.PIPELINE + route_pipeline_handoff_to_normal = False + if pipeline_mode: + route_pipeline_handoff_to_normal = await self._should_route_pipeline_handoff_to_normal( + context_id=context_id, + cwd=cwd, + ) + pipeline_input: PipelineUserInput | None = None + if pipeline_mode and not route_pipeline_handoff_to_normal: + try: + pipeline_input = self._pipeline_input_from_context(context, cwd=cwd) + except ValueError as exc: + raise InvalidParamsError(sanitize_public_text(str(exc))) from exc + prompt = pipeline_input.display_text + self._validate_pipeline_request_input(pipeline_input, model=model) + else: + prompt = self._prompt_from_context(context, cwd=cwd) if pipeline_mode and requested_task_id is None: recovered_task_id = await self._recoverable_pipeline_task_id_for_context(context_id=context_id, cwd=cwd) if recovered_task_id is not None: @@ -107,10 +828,12 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non owner=owner, restore_interrupted=not pipeline_mode, ) - if not isinstance(getattr(context, "current_task", None), Task): - await self._publish_initial_task(event_queue, task_id=task_id, context_id=context_id, context=context) + await publish_initial_task_if_missing() await self._task_store.ensure_task_not_expired(task.task_id) + except InvalidParamsError: + raise except Exception as exc: + await publish_initial_task_if_missing() if _is_retryable_executor_error(exc): await self._publish_status( event_queue, @@ -140,7 +863,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non self._metrics.record_task_failed() return - if not prompt.strip(): + if not (pipeline_mode and not route_pipeline_handoff_to_normal) and not prompt.strip(): task.state = TASK_STATE_FAILED await self._publish_status( event_queue, @@ -154,11 +877,8 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non self._metrics.record_task_failed() return - route_pipeline_handoff_to_normal = pipeline_mode and await self._should_route_pipeline_handoff_to_normal( - context_id=context_id, - cwd=cwd, - ) if pipeline_mode and not route_pipeline_handoff_to_normal: + assert pipeline_input is not None pipeline_executor = IacCodeA2APipelineExecutor( task_store=self._task_store, model=model, @@ -176,7 +896,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non task_id=task_id, context_id=context_id, cwd=cwd, - prompt=prompt, + pipeline_input=pipeline_input, ) return if route_pipeline_handoff_to_normal: @@ -288,7 +1008,28 @@ def runtime_factory(session_id: str) -> Any: with use_session_id(ctx.session_id), user_id_ctx, aliyun_credential_ctx: self._configure_runtime_model(runtime, model, from_metadata=metadata_model is not None) self._refresh_runtime_cloud_tools(runtime) - async for event in runtime.agent_loop.run_streaming(prompt): + cleanup_ledger = _cleanup_ledger_for_a2a_normal_chat(cwd=cwd, session_id=ctx.session_id) + _prune_completed_cleanup_prompt_from_runtime(runtime, cleanup_ledger) + cleanup_publisher = None + if cleanup_ledger is not None: + cleanup_publisher = _cleanup_publisher_for_a2a_normal_chat( + event_queue=event_queue, + cwd=cwd, + session_id=ctx.session_id, + task_id=task_id, + context_id=context_id, + artifact_store=self._artifact_store, + exposure_types=self._thinking_exposure_types, + ) + stream = _stream_a2a_normal_events( + runtime=runtime, + prompt=prompt, + cleanup_ledger=cleanup_ledger, + cleanup_publisher=cleanup_publisher, + cwd=cwd, + session_id=ctx.session_id, + ) + async for event in stream: text_chunk = await publish_stream_event( event_queue, task_id=task_id, @@ -477,6 +1218,43 @@ def _prompt_from_context(self, context: RequestContext, *, cwd: str) -> str: return context.get_user_input() return parts_to_prompt(message.parts, cwd=cwd) + def _pipeline_input_from_context(self, context: RequestContext, *, cwd: str) -> PipelineUserInput: + message = getattr(context, "message", None) + if not isinstance(message, Message): + return normalize_pipeline_user_input(context.get_user_input()) + return parts_to_pipeline_input(message.parts, cwd=cwd) + + def validate_pipeline_message_request(self, message: Message) -> None: + metadata = getattr(message, "metadata", None) + try: + cwd = self._resolve_cwd(metadata) + pipeline_input = parts_to_pipeline_input(message.parts, cwd=cwd) + except ValueError as exc: + raise InvalidParamsError(sanitize_public_text(str(exc))) from exc + model = self._resolve_model(metadata) or self._model + self._validate_pipeline_request_input(pipeline_input, model=model) + + def _validate_pipeline_request_input(self, pipeline_input: PipelineUserInput, *, model: str | None = None) -> None: + if pipeline_input.is_empty: + raise InvalidParamsError("A2A server received empty input.") + model = model or self._model + if pipeline_input.has_images and not self._model_supports_image_input(model=model): + raise InvalidParamsError(_("Current model {model} does not support image input.").format(model=model)) + + def _model_supports_image_input(self, *, model: str | None = None) -> bool: + model = model or self._model + provider_key = get_active_provider_key() + provider_config = get_provider_config(provider_key) if provider_key else {} + api_base = provider_config.get("apiBase") if isinstance(provider_config.get("apiBase"), str) else None + credentials = load_credentials(model=model) + api_key = credentials.get(provider_key, "") if provider_key else None + return is_model_multimodal( + model, + provider_key=provider_key, + base_url=api_base, + api_key=api_key, + ) + def _sanitize_error(self, exc: Exception) -> str: if isinstance(exc, ValueError): msg = str(exc).lower() @@ -496,11 +1274,10 @@ async def _should_route_pipeline_handoff_to_normal(self, *, context_id: str, cwd return False if ctx.cwd != cwd: return False - snapshot = A2APipelineSnapshotStore( - existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=ctx.session_id) - ).load() - if not isinstance(snapshot, dict): + state = _a2a_pipeline_state_for_session(cwd=cwd, session_id=ctx.session_id) + if state is None: return False + _snapshot_store, _journal, snapshot, _journal_events = state handoff = snapshot.get("normalHandoff") if not isinstance(handoff, dict): return False @@ -513,26 +1290,47 @@ async def _ensure_pipeline_handoff_context_in_session(self, *, context_id: str, return if ctx.cwd != cwd: return - snapshot = A2APipelineSnapshotStore( - existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=ctx.session_id) - ).load() - if not isinstance(snapshot, dict): + state = _a2a_pipeline_state_for_session(cwd=cwd, session_id=ctx.session_id) + if state is None: return + _snapshot_store, _journal, snapshot, _journal_events = state handoff = snapshot.get("normalHandoff") if not isinstance(handoff, dict): return summary = handoff.get("summary") - if not isinstance(summary, str) or not summary: + cleanup_payload = None + data = handoff.get("data") + if isinstance(data, dict) and isinstance(data.get("cleanup"), dict): + cleanup_payload = _cleanup_payload_from_private_ledger_or_unavailable( + ledger_path=_default_cleanup_ledger_path(cwd=cwd, session_id=ctx.session_id), + ) + cleanup_prompt = cleanup_payload.get("prompt") if isinstance(cleanup_payload, dict) else None + cleanup_ledger_path = cleanup_payload.get("ledgerPath") if isinstance(cleanup_payload, dict) else None + if not isinstance(cleanup_prompt, str) or not cleanup_prompt: + cleanup_prompt = None + if not isinstance(cleanup_ledger_path, str) or not cleanup_ledger_path: + cleanup_ledger_path = None + if (not isinstance(summary, str) or not summary) and cleanup_prompt is None: return session_storage = SessionStorage() messages = session_storage.load(cwd, ctx.session_id) - if any( - getattr(message, "role", None) == "user" and getattr(message, "content", None) == summary - for message in messages + if isinstance(summary, str) and summary and not _session_has_user_message(messages, content=summary): + session_storage.append(cwd, ctx.session_id, AgentMessage(role="user", content=summary)) + messages.append(AgentMessage(role="user", content=summary)) + if cleanup_prompt is not None and not _session_has_active_cleanup_prompt_content( + messages, + content=cleanup_prompt, ): - return - session_storage.append(cwd, ctx.session_id, AgentMessage(role="user", content=summary)) + session_storage.append( + cwd, + ctx.session_id, + create_cleanup_prompt_message( + cleanup_prompt, + cleanup_ledger_path=cleanup_ledger_path, + cleanup_status="pending" if cleanup_ledger_path else None, + ), + ) async def _recoverable_pipeline_task_id_for_context(self, *, context_id: str, cwd: str) -> str | None: try: diff --git a/src/iac_code/a2a/jsonrpc_passthrough.py b/src/iac_code/a2a/jsonrpc_passthrough.py new file mode 100644 index 00000000..75898b1b --- /dev/null +++ b/src/iac_code/a2a/jsonrpc_passthrough.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from types import MethodType +from typing import Any, AsyncIterable, AsyncIterator + +from a2a.server.context import ServerCallContext +from jsonrpc.jsonrpc2 import JSONRPC20Response +from sse_starlette.sse import EventSourceResponse +from starlette.responses import Response + +logger = logging.getLogger(__name__) + + +def install_jsonrpc_error_data_passthrough() -> None: + try: + from a2a.server.request_handlers import response_helpers + from a2a.server.routes import jsonrpc_dispatcher + except Exception: + return + current = response_helpers.build_error_response + if getattr(current, "_iac_code_recoverable_data_passthrough", False): + return + original = current + + def build_error_response_with_passthrough(request_id: str | int | None, error: Any) -> dict[str, Any]: + if getattr(error, "jsonrpc_error_data_passthrough", False): + payload = { + "code": int(getattr(error, "code", -32603)), + "message": str(error), + } + data = getattr(error, "data", None) + if data is not None: + payload["data"] = data + return JSONRPC20Response(error=payload, _id=request_id).data + return original(request_id, error) + + setattr(build_error_response_with_passthrough, "_iac_code_recoverable_data_passthrough", True) + setattr(response_helpers, "build_error_response", build_error_response_with_passthrough) + setattr(jsonrpc_dispatcher, "build_error_response", build_error_response_with_passthrough) + + +def install_v03_jsonrpc_error_data_passthrough(jsonrpc_endpoint: Callable[..., Awaitable[Response]]) -> None: + dispatcher = getattr(jsonrpc_endpoint, "__self__", None) + adapter = getattr(dispatcher, "_v03_adapter", None) + if adapter is None or getattr(adapter, "_iac_code_recoverable_error_passthrough", False): + return + + try: + from a2a.compat.v0_3 import types as types_v03 + except Exception: + logger.debug("A2A v0.3 compatibility types are unavailable", exc_info=True) + return + + async def _process_streaming_request_with_passthrough( + self: Any, + request_id: str | int | None, + request_obj: Any, + context: ServerCallContext, + ) -> EventSourceResponse: + method = request_obj.method + if method == "message/stream": + stream_gen = self.handler.on_message_send_stream(request_obj, context) + elif method == "tasks/resubscribe": + stream_gen = self.handler.on_subscribe_to_task(request_obj, context) + else: + raise ValueError(f"Unsupported streaming method {method}") + + async def event_generator(stream: AsyncIterable[Any]) -> AsyncIterator[dict[str, str]]: + try: + async for item in stream: + yield {"data": item.model_dump_json(by_alias=True, exclude_none=True)} + except Exception as exc: + logger.exception("Error during stream generation in v0.3 JSONRPCAdapter") + if getattr(exc, "jsonrpc_error_data_passthrough", False): + error = types_v03.InvalidParamsError(message=str(exc), data=getattr(exc, "data", None)) + else: + error = types_v03.InternalError(message=str(exc)) + err_resp = types_v03.SendStreamingMessageResponse( + root=types_v03.JSONRPCErrorResponse(id=request_id, error=error) + ) + yield {"data": err_resp.model_dump_json(by_alias=True, exclude_none=True)} + + return EventSourceResponse(event_generator(stream_gen)) + + adapter._process_streaming_request = MethodType(_process_streaming_request_with_passthrough, adapter) + adapter._iac_code_recoverable_error_passthrough = True diff --git a/src/iac_code/a2a/parts.py b/src/iac_code/a2a/parts.py index 0b546cc2..d28fded1 100644 --- a/src/iac_code/a2a/parts.py +++ b/src/iac_code/a2a/parts.py @@ -13,6 +13,10 @@ from google.protobuf.json_format import MessageToDict +from iac_code.agent.message import ContentBlock, ImageBlock, TextBlock +from iac_code.pipeline.engine.user_input import PipelineUserInput, content_display_text, content_has_images +from iac_code.utils.image.resizer import maybe_resize_and_downsample + MAX_INLINE_BYTES = 1024 * 1024 MAX_FILE_BYTES = 1024 * 1024 MAX_BINARY_INLINE_BYTES = 5 * 1024 * 1024 @@ -38,6 +42,7 @@ ) TEXT_LIKE_MIME_TYPES = frozenset(DEFAULT_TEXT_LIKE_MIME_TYPES) MULTIMODAL_MIME_TYPES = frozenset(DEFAULT_MULTIMODAL_MIME_TYPES) +SUPPORTED_IMAGE_MIME_TYPES = frozenset(("image/png", "image/jpeg", "image/webp", "image/gif")) SUPPORTED_INPUT_MIME_TYPES = [*DEFAULT_TEXT_LIKE_MIME_TYPES, *DEFAULT_MULTIMODAL_MIME_TYPES] @@ -108,6 +113,66 @@ def parts_to_prompt(message_parts: Iterable[Any], *, cwd: str | Path) -> str: return "\n".join(value for value in values if value) +def parts_to_pipeline_input(message_parts: Iterable[Any], *, cwd: str | Path) -> PipelineUserInput: + blocks: list[ContentBlock] = [] + for part in message_parts: + converted = part_to_pipeline_block(part, cwd=cwd) + if isinstance(converted, list): + blocks.extend(converted) + elif converted: + blocks.append(TextBlock(text=converted)) + if content_has_images(blocks): + return PipelineUserInput( + content=blocks, + display_text=content_display_text(blocks), + has_images=True, + ) + text = "\n".join(block.text for block in blocks if isinstance(block, TextBlock)) + return PipelineUserInput(content=text, display_text=text, has_images=False) + + +def part_to_pipeline_block(part: Any, *, cwd: str | Path) -> str | list[ContentBlock]: + media_type = _media_type(part) + if _has_field(part, "text"): + _ensure_text_like(media_type) + return str(part.text) + if _has_field(part, "data"): + if media_type in SUPPORTED_IMAGE_MIME_TYPES: + return [_image_block_from_binary(_binary_data_part_bytes(part), requested_media_type=media_type)] + if _is_multimodal(media_type): + raise ValueError("A2A pipeline input has unsupported image media type.") + if media_type != "application/json": + raise ValueError("A2A data parts must use application/json media type.") + data = MessageToDict(part.data, preserving_proto_field_name=False) + serialized = json.dumps(data, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + _ensure_size(serialized.encode("utf-8"), limit=MAX_INLINE_BYTES, label="A2A data part") + return serialized + if _has_field(part, "raw"): + raw = bytes(part.raw) + if media_type in SUPPORTED_IMAGE_MIME_TYPES: + _ensure_size(raw, limit=MAX_BINARY_INLINE_BYTES, label="A2A binary raw part") + return [_image_block_from_binary(raw, requested_media_type=media_type)] + if _is_multimodal(media_type): + raise ValueError("A2A pipeline input has unsupported image media type.") + _ensure_text_like(media_type) + _ensure_size(raw, limit=MAX_INLINE_BYTES, label="A2A raw part") + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("A2A raw parts must contain valid UTF-8.") from exc + if _has_field(part, "url"): + if media_type in SUPPORTED_IMAGE_MIME_TYPES: + path = _safe_file_url_path(str(part.url), cwd=Path(cwd)) + if path.stat().st_size > MAX_BINARY_FILE_BYTES: + raise ValueError("A2A binary file URL part content is too large.") + return [_image_block_from_binary(path.read_bytes(), requested_media_type=media_type)] + if _is_multimodal(media_type): + raise ValueError("A2A pipeline input has unsupported image media type.") + _ensure_text_like(media_type) + return _read_file_url_part(str(part.url), cwd=Path(cwd)) + raise ValueError("A2A server supports text, JSON data, raw text, or workspace file URL parts only.") + + def part_to_prompt(part: Any, *, cwd: str | Path) -> str: media_type = _media_type(part) if _has_field(part, "text"): @@ -188,6 +253,20 @@ def _filename(part: Any) -> str: def _binary_data_part_to_manifest(part: Any, *, media_type: str) -> str: + data = MessageToDict(part.data, preserving_proto_field_name=False) + if not isinstance(data, dict): + raise ValueError("A2A binary data parts must contain an object.") + content = _binary_data_part_bytes(part) + filename = str(data.get("filename") or _filename(part) or "inline") + return _multimodal_manifest( + filename=os.path.basename(filename), + media_type=media_type, + content=content, + source="data", + ) + + +def _binary_data_part_bytes(part: Any) -> bytes: data = MessageToDict(part.data, preserving_proto_field_name=False) if not isinstance(data, dict): raise ValueError("A2A binary data parts must contain an object.") @@ -199,12 +278,16 @@ def _binary_data_part_to_manifest(part: Any, *, media_type: str) -> str: except (ValueError, UnicodeEncodeError) as exc: raise ValueError("A2A binary data part bytes must be valid base64.") from exc _ensure_size(content, limit=MAX_BINARY_INLINE_BYTES, label="A2A binary data part") - filename = str(data.get("filename") or _filename(part) or "inline") - return _multimodal_manifest( - filename=os.path.basename(filename), - media_type=media_type, - content=content, - source="data", + return content + + +def _image_block_from_binary(raw: bytes, *, requested_media_type: str) -> ImageBlock: + if requested_media_type not in SUPPORTED_IMAGE_MIME_TYPES: + raise ValueError("A2A pipeline input has unsupported image media type.") + resized = maybe_resize_and_downsample(raw) + return ImageBlock( + media_type=resized.media_type, + data=base64.b64encode(resized.data).decode("ascii"), ) diff --git a/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index d206c967..ff011887 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -40,11 +40,24 @@ "from_step": "fromStep", "parent_step_id": "parentStepId", "pipeline_type": "pipelineType", + "progress_status": "progressStatus", "rollback_target": "rollbackTarget", + "cleanup_status": "cleanupStatus", + "cleanup_tool_use_id": "cleanupToolUseId", + "last_error": "lastError", + "progress_percentage": "progressPercentage", + "resource_count": "resourceCount", + "resource_id": "resourceId", + "resource_name": "resourceName", + "resource_type": "resourceType", + "region_id": "regionId", "selected_index": "selectedIndex", "selected_option": "selectedOption", "selected_value": "selectedValue", + "source_step_id": "sourceStepId", "stale_fields": "staleFields", + "stack_status": "stackStatus", + "status_message": "statusMessage", "step_id": "stepId", "step_index": "stepIndex", "step_names": "stepNames", @@ -325,6 +338,16 @@ def _translate_pipeline_event(self, event: PipelineEvent) -> list[dict[str, Any] return [self._envelope("pipeline_started", "pipeline", "working", _event_data(data), created_at=created_at)] if event.type == PipelineEventType.PIPELINE_RESUMED: return [self._envelope("pipeline_resumed", "pipeline", "working", _event_data(data), created_at=created_at)] + if event.type == PipelineEventType.PIPELINE_WARNING: + return [ + self._envelope( + "pipeline_warning", + "pipeline", + "working", + _warning_event_data(data), + created_at=created_at, + ) + ] if event.type == PipelineEventType.PIPELINE_COMPLETED: event_type = "pipeline_failed" if data.get("failed") is True else "pipeline_completed" status = "failed" if event_type == "pipeline_failed" else "completed" @@ -541,7 +564,7 @@ def _translate_sub_pipeline_stream_event(self, event: SubPipelineStreamEvent) -> return envelopes def _translate_text_delta_event(self, event: TextDeltaEvent) -> dict[str, Any]: - return self._envelope("text_delta", "pipeline", "working", {"text": event.text}) + return self._translate_parent_scoped_display_event("text_delta", {"text": event.text}) def _translate_ask_user_question_event(self, event: AskUserQuestionEvent) -> dict[str, Any]: envelope = self._translate_parent_scoped_display_event("input_required", _ask_user_question_data(event)) @@ -550,7 +573,7 @@ def _translate_ask_user_question_event(self, event: AskUserQuestionEvent) -> dic return envelope def _translate_permission_request_event(self, event: PermissionRequestEvent) -> dict[str, Any]: - envelope = self._envelope("permission_requested", "pipeline", "working", _permission_request_data(event)) + envelope = self._translate_parent_scoped_display_event("permission_requested", _permission_request_data(event)) envelope["permission"] = _permission_request_metadata(event) return envelope @@ -586,7 +609,7 @@ def _translate_tool_result_event(self, event: ToolResultEvent) -> list[dict[str, stack_envelope = self._translate_stack_current_changed_event(event) if stack_envelope is not None: envelopes.append(stack_envelope) - envelopes.append(self._envelope("tool_result", "pipeline", "working", _tool_result_data(event))) + envelopes.append(self._translate_parent_scoped_display_event("tool_result", _tool_result_data(event))) return envelopes def _remember_tool_input(self, event: ToolUseEndEvent) -> None: @@ -662,6 +685,11 @@ def _stack_current_changed_data(self, event: ToolResultEvent) -> dict[str, Any] if stack_id is None: return None + stack_status = _first_string_from_sources((result,), ("StackStatus", "stackStatus", "status")) + is_delete_complete = action in _STACK_CLEAR_ACTIONS and is_success and stack_status == "DELETE_COMPLETE" + if action in _STACK_CLEAR_ACTIONS and is_success and stack_status is None: + stack_status = "DELETE_REQUESTED" + data: dict[str, Any] = { "toolName": event.tool_name, "toolUseId": event.tool_use_id, @@ -670,11 +698,11 @@ def _stack_current_changed_data(self, event: ToolResultEvent) -> dict[str, Any] "regionId": operation["regionId"], "stackId": stack_id, "stackName": _first_string_from_sources((result, params), ("StackName", "stackName", "stack_name", "name")), - "stackStatus": _first_string_from_sources((result,), ("StackStatus", "stackStatus", "status")), + "stackStatus": stack_status, "isSuccess": is_success, - "current": False if action in _STACK_CLEAR_ACTIONS and is_success else True, + "current": False if is_delete_complete else True, } - if action in _STACK_CLEAR_ACTIONS and is_success: + if is_delete_complete: data["cleared"] = True return {key: value for key, value in data.items() if value is not None} @@ -944,6 +972,11 @@ def _event_data(data: dict[str, Any]) -> dict[str, Any]: } +def _warning_event_data(data: dict[str, Any]) -> dict[str, Any]: + private_keys = {"ledger_path", "ledgerPath", "load_error", "loadError"} + return _event_data({key: value for key, value in data.items() if str(key) not in private_keys}) + + def _sanitize_event_value(key: str, value: Any) -> Any: key_lower = key.lower() if isinstance(value, str): diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index 02665789..eef50b42 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -10,6 +10,7 @@ import httpx from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent +from a2a.utils.errors import InvalidParamsError from iac_code.a2a.events import make_text_part from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator @@ -30,15 +31,20 @@ ) from iac_code.agent.message import Message as AgentMessage from iac_code.i18n import _ -from iac_code.pipeline import create_pipeline +from iac_code.pipeline import create_pipeline, discover_pipelines from iac_code.pipeline.config import get_pipeline_name +from iac_code.pipeline.engine.cleanup import CleanupLedger from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType -from iac_code.pipeline.engine.handoff import terminal_outcome_from_completed_event +from iac_code.pipeline.engine.handoff import build_handoff_summary, terminal_outcome_from_completed_event +from iac_code.pipeline.engine.loader import load_pipeline_dir from iac_code.pipeline.engine.public_errors import public_error +from iac_code.pipeline.engine.session import PipelineSession +from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime from iac_code.services.session_storage import SessionStorage from iac_code.services.telemetry import use_session_id from iac_code.types.stream_events import AskUserQuestionEvent, SubPipelineStreamEvent +from iac_code.utils.public_errors import sanitize_public_text logger = logging.getLogger(__name__) _CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1 @@ -69,6 +75,27 @@ def _auth_error_text() -> str: return _("Authentication required. Configure credentials and retry.") +class RecoverablePipelineInvalidParamsError(InvalidParamsError): + code = -32602 + jsonrpc_error_data_passthrough = True + + +def _active_sidecar_mismatch_error( + *, + recoverable_task_id: str, + context_id: str, + sidecar_status: str, +) -> RecoverablePipelineInvalidParamsError: + return RecoverablePipelineInvalidParamsError( + _("Pipeline already running. Resume task {task_id}.").format(task_id=recoverable_task_id), + data={ + "recoverableTaskId": recoverable_task_id, + "contextId": context_id, + "sidecarStatus": sidecar_status, + }, + ) + + @dataclass class A2APipelineRuntime: agent_runtime: Any @@ -156,8 +183,13 @@ async def execute( task_id: str, context_id: str, cwd: str, - prompt: str, + pipeline_input: PipelineUserInput | str | None = None, + prompt: str | None = None, ) -> None: + if pipeline_input is None: + pipeline_input = prompt or "" + pipeline_input = normalize_pipeline_user_input(pipeline_input) + prompt = pipeline_input.display_text session_storage = SessionStorage() def runtime_factory(session_id: str) -> Any: @@ -192,7 +224,7 @@ def runtime_factory(session_id: str) -> Any: task_id=task_id, context_id=context_id, cwd=cwd, - prompt=prompt, + pipeline_input=pipeline_input, preserve_task_record=preserve_active_task, ) if routed: @@ -215,11 +247,7 @@ def runtime_factory(session_id: str) -> Any: try: owner_task = asyncio.current_task() - ctx.active_task_id = task.task_id - task.active_task = owner_task - task.state = TASK_STATE_WORKING - self._task_store.mirror_task(task) - self._task_store.mirror_context(ctx) + task_persistence_started = False pipeline = None publisher: PipelineA2AEventPublisher | None = None @@ -267,6 +295,7 @@ def fresh_pipeline_factory() -> Any: selected = self._select_stream( pipeline, prompt, + pipeline_input=pipeline_input, publisher=publisher, task_id=task_id, context_id=context_id, @@ -277,6 +306,12 @@ def fresh_pipeline_factory() -> Any: pipeline_runtime.pipeline = pipeline self._task_store.mirror_context(ctx) stream = selected.stream + ctx.active_task_id = task.task_id + task.active_task = owner_task + task.state = TASK_STATE_WORKING + task_persistence_started = True + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) stream_had_events = False with use_session_id(ctx.session_id): while True: @@ -291,7 +326,7 @@ def fresh_pipeline_factory() -> Any: if not stream_result.restart_requested: break - stream = self._continue_after_interrupt_stream(pipeline, prompt) + stream = self._continue_after_interrupt_stream(pipeline, pipeline_input) terminal_status_published = False terminal_sidecar = _is_terminal_sidecar_status(getattr(pipeline, "sidecar_status", None)) @@ -352,7 +387,10 @@ def fresh_pipeline_factory() -> Any: ) await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) self._record_state(task.state) + except RecoverablePipelineInvalidParamsError: + raise except Exception as exc: + task_persistence_started = True await self._publish_exception_status( event_queue, task=task, @@ -367,8 +405,9 @@ def fresh_pipeline_factory() -> Any: if ctx.active_task_id == task.task_id: ctx.active_task_id = None ctx.touch() - task.touch() - self._task_store.mirror_task(task) + if task_persistence_started: + task.touch() + self._task_store.mirror_task(task) self._task_store.mirror_context(ctx) await _flush_telemetry_safely() finally: @@ -392,15 +431,29 @@ async def _route_active_pipeline_interrupt( task_id: str, context_id: str, cwd: str, - prompt: str, + pipeline_input: PipelineUserInput, preserve_task_record: bool, ) -> bool: + pipeline_input = normalize_pipeline_user_input(pipeline_input) + prompt = pipeline_input.display_text runtime = ctx.runtime pipeline = getattr(runtime, "pipeline", None) if pipeline is None: return False - pending_question_route = await self._route_pending_question_answer(runtime, prompt) + try: + pending_question_route = await self._route_pending_question_answer(runtime, pipeline_input) + except Exception as exc: + await self._publish_exception_status( + event_queue, + task=task, + task_id=task_id, + context_id=context_id, + exc=exc, + preserve_task_record=preserve_task_record, + pipeline_publisher=getattr(runtime, "publisher", None), + ) + return True if pending_question_route == _PENDING_QUESTION_ANSWERED: task.state = TASK_STATE_WORKING self._task_store.mirror_task(task) @@ -442,7 +495,7 @@ async def _route_active_pipeline_interrupt( task_id=task_id, context_id=context_id, session_id=ctx.session_id, - prompt=prompt, + pipeline_input=pipeline_input, preserve_task_record=preserve_task_record, ) return True @@ -465,12 +518,21 @@ async def _route_active_pipeline_interrupt( await _maybe_await(pause_agent_loops()) paused = True - verdict = await _maybe_await(handler(prompt)) + runner_input = _pipeline_runner_input(pipeline_input) + verdict = await _maybe_await(handler(runner_input)) parent_rollback: bool | None = None if getattr(verdict, "action", "") == "hard_interrupt": apply_hard_interrupt = getattr(pipeline, "apply_hard_interrupt", None) if callable(apply_hard_interrupt): - parent_rollback = bool(await _maybe_await(apply_hard_interrupt(verdict))) + parameters = inspect.signature(apply_hard_interrupt).parameters + if pipeline_input.has_images and ( + "source_input" in parameters + or any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()) + ): + applied = apply_hard_interrupt(verdict, source_input=runner_input) + else: + applied = apply_hard_interrupt(verdict) + parent_rollback = bool(await _maybe_await(applied)) if parent_rollback: runtime.restart_after_interrupt = True _restart_requested_event(runtime).set() @@ -537,9 +599,11 @@ async def _continue_active_pause_confirmation( task_id: str, context_id: str, session_id: str, - prompt: str, + pipeline_input: PipelineUserInput, preserve_task_record: bool, ) -> None: + pipeline_input = normalize_pipeline_user_input(pipeline_input) + prompt = pipeline_input.display_text owner_task = asyncio.current_task() task.active_task = owner_task ctx.active_task_id = task_id @@ -552,7 +616,10 @@ async def _continue_active_pause_confirmation( self._task_store.mirror_task(task) self._task_store.mirror_context(ctx) try: - stream = pipeline.continue_from_sidecar(user_input=prompt) if prompt else pipeline.continue_from_sidecar() + if prompt: + stream = pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input)) + else: + stream = pipeline.continue_from_sidecar() task.state = TASK_STATE_WORKING self._task_store.mirror_task(task) with use_session_id(session_id): @@ -565,7 +632,7 @@ async def _continue_active_pause_confirmation( ) if not stream_result.restart_requested: break - stream = self._continue_after_interrupt_stream(pipeline, prompt) + stream = self._continue_after_interrupt_stream(pipeline, pipeline_input) snapshot = publisher.snapshot_store.load() or {} task.state = _task_state_from_pipeline(pipeline, snapshot) @@ -609,6 +676,7 @@ def _create_pipeline( session_id=session_id, cwd=cwd, resume_from_sidecar=resume_from_sidecar, + surface="a2a", ) def _set_pipeline_telemetry_correlation(self, pipeline: Any, *, task_id: str, context_id: str) -> None: @@ -620,11 +688,11 @@ def _set_pipeline_telemetry_correlation(self, pipeline: Any, *, task_id: str, co except Exception: logger.warning("A2A pipeline telemetry correlation setup failed", exc_info=True) - def _continue_after_interrupt_stream(self, pipeline: Any, prompt: str) -> AsyncIterator[Any]: + def _continue_after_interrupt_stream(self, pipeline: Any, pipeline_input: PipelineUserInput) -> AsyncIterator[Any]: continue_after_interrupt = getattr(pipeline, "continue_after_interrupt", None) if callable(continue_after_interrupt): return continue_after_interrupt() - return pipeline.run(prompt) + return pipeline.run(_pipeline_runner_input(pipeline_input)) async def _consume_stream_until_restart( self, @@ -757,6 +825,7 @@ def _select_stream( pipeline: Any, prompt: str, *, + pipeline_input: PipelineUserInput, publisher: PipelineA2AEventPublisher, task_id: str, context_id: str, @@ -766,8 +835,11 @@ def _select_stream( if status == "waiting_input": _raise_if_sidecar_restore_failed(pipeline, status) if not _sidecar_matches_task(publisher, task_id=task_id, context_id=context_id, sidecar_status=status): - pipeline = self._fresh_pipeline_after_sidecar_mismatch(pipeline, fresh_pipeline_factory) - return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.run(prompt)) + raise _active_sidecar_mismatch_error_from_publisher( + publisher, + context_id=context_id, + sidecar_status=status, + ) pending_ask = _pending_ask_input_from_sidecar( publisher, task_id=task_id, @@ -781,6 +853,7 @@ def _select_stream( publisher=publisher, pending_input=pending_ask, prompt=prompt, + pipeline_input=pipeline_input, ), ) pending_pause = _pending_pipeline_pause_input_from_sidecar( @@ -790,15 +863,23 @@ def _select_stream( ) if pending_pause is not None: stream = ( - pipeline.continue_from_sidecar(user_input=prompt) if prompt else pipeline.continue_from_sidecar() + pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input)) + if prompt + else pipeline.continue_from_sidecar() ) return _SelectedPipelineStream(pipeline=pipeline, stream=stream) - return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.resume(prompt)) + return _SelectedPipelineStream( + pipeline=pipeline, + stream=pipeline.resume(_pipeline_runner_input(pipeline_input)), + ) if status == "running": _raise_if_sidecar_restore_failed(pipeline, status) if not _sidecar_matches_task(publisher, task_id=task_id, context_id=context_id, sidecar_status=status): - pipeline = self._fresh_pipeline_after_sidecar_mismatch(pipeline, fresh_pipeline_factory) - return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.run(prompt)) + raise _active_sidecar_mismatch_error_from_publisher( + publisher, + context_id=context_id, + sidecar_status=status, + ) pending_ask = _pending_ask_input_from_sidecar( publisher, task_id=task_id, @@ -812,6 +893,7 @@ def _select_stream( publisher=publisher, pending_input=pending_ask, prompt=prompt, + pipeline_input=pipeline_input, ), ) pending_pause = _pending_pipeline_pause_input_from_sidecar( @@ -821,20 +903,29 @@ def _select_stream( ) if pending_pause is not None: stream = ( - pipeline.continue_from_sidecar(user_input=prompt) if prompt else pipeline.continue_from_sidecar() + pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input)) + if prompt + else pipeline.continue_from_sidecar() ) return _SelectedPipelineStream(pipeline=pipeline, stream=stream) if prompt: return _SelectedPipelineStream( - pipeline=pipeline, stream=pipeline.continue_from_sidecar(user_input=prompt) + pipeline=pipeline, + stream=pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input)), ) return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.continue_from_sidecar()) if status in _TERMINAL_SIDECAR_STATUSES: if _terminal_sidecar_matches_task(publisher, status, task_id=task_id, context_id=context_id): return _SelectedPipelineStream(pipeline=pipeline, stream=_empty_stream()) pipeline = self._fresh_pipeline_after_sidecar_mismatch(pipeline, fresh_pipeline_factory) - return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.run(prompt)) - return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.run(prompt)) + return _SelectedPipelineStream( + pipeline=pipeline, + stream=pipeline.run(_pipeline_runner_input(pipeline_input)), + ) + return _SelectedPipelineStream( + pipeline=pipeline, + stream=pipeline.run(_pipeline_runner_input(pipeline_input)), + ) def _fresh_pipeline_after_sidecar_mismatch( self, @@ -956,16 +1047,21 @@ async def _publish_normal_handoff_ready( logger.warning("Failed to build A2A pipeline normal handoff event", exc_info=True) return + data = { + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": outcome, + "summary": summary, + } + cleanup = _pipeline_cleanup_handoff_data(pipeline) + if cleanup is not None: + data["cleanup"] = cleanup + published = await publisher.publish_manual( "pipeline_handoff_ready", "pipeline", status=_handoff_status_from_outcome(outcome), - data={ - "action": "switch_to_normal", - "targetMode": "normal", - "outcome": outcome, - "summary": summary, - }, + data=data, ) if published is not None: _persist_normal_handoff_summary(pipeline, summary) @@ -986,7 +1082,9 @@ def _track_pending_question( return runtime.pending_question = _PendingAskUserQuestion(event=question, envelope=dict(envelope)) - async def _route_pending_question_answer(self, runtime: Any, prompt: str) -> str: + async def _route_pending_question_answer(self, runtime: Any, pipeline_input: PipelineUserInput) -> str: + pipeline_input = normalize_pipeline_user_input(pipeline_input) + prompt = pipeline_input.display_text pending = getattr(runtime, "pending_question", None) if not isinstance(pending, _PendingAskUserQuestion): return _PENDING_QUESTION_NOT_ROUTED @@ -998,11 +1096,12 @@ async def _route_pending_question_answer(self, runtime: Any, prompt: str) -> str return _PENDING_QUESTION_STALE_FINISHED publisher = getattr(runtime, "publisher", None) - if not isinstance(publisher, PipelineA2AEventPublisher): + publish_manual = getattr(publisher, "publish_manual", None) + if not callable(publish_manual): return _PENDING_QUESTION_NOT_ROUTED answer = _ask_user_question_answer_from_prompt(question, prompt) - published = await publisher.publish_manual( + published = await publish_manual( "input_received", str(pending.envelope.get("scope") or "pipeline"), status="working", @@ -1020,10 +1119,56 @@ async def _route_pending_question_answer(self, runtime: Any, prompt: str) -> str if published is None: return _PENDING_QUESTION_NOT_ROUTED + if pipeline_input.has_images: + inject_pending_question_supplement = getattr( + getattr(runtime, "pipeline", None), + "inject_pending_question_supplement", + None, + ) + if callable(inject_pending_question_supplement): + try: + injected = inject_pending_question_supplement(pipeline_input.content, envelope=pending.envelope) + if inspect.isawaitable(injected): + injected = await injected + except Exception: + await self._restore_pending_question_input_required(runtime, pending) + raise + if injected is False: + await self._restore_pending_question_input_required(runtime, pending) + raise RuntimeError("A2A ask_user_question image supplement could not be delivered.") + else: + await self._restore_pending_question_input_required(runtime, pending) + raise RuntimeError("A2A pipeline cannot accept ask_user_question image supplement.") future.set_result(answer) runtime.pending_question = None return _PENDING_QUESTION_ANSWERED + async def _restore_pending_question_input_required(self, runtime: Any, pending: "_PendingAskUserQuestion") -> None: + publisher = getattr(runtime, "publisher", None) + publish_manual = getattr(publisher, "publish_manual", None) + if not callable(publish_manual): + return + question = pending.event + envelope = pending.envelope if isinstance(pending.envelope, dict) else {} + data = { + "kind": "ask_user_question", + "inputId": _pending_input_id(envelope, question), + "toolUseId": question.tool_use_id, + "question": question.question, + "prompt": question.question, + "options": question.options if isinstance(question.options, list) else [], + "allowFreeText": question.allow_free_text, + "freeTextPrompt": question.free_text_prompt, + "required": True, + } + await publish_manual( + "input_required", + str(envelope.get("scope") or "pipeline"), + status="input_required", + data=data, + coordinates=_coordinates_from_envelope(envelope), + ) + async def _fail_already_active( self, event_queue: Any, @@ -1143,13 +1288,19 @@ async def _empty_stream() -> AsyncIterator[Any]: yield None +def _pipeline_runner_input(pipeline_input: PipelineUserInput) -> PipelineUserInput | str: + return pipeline_input if pipeline_input.has_images else pipeline_input.display_text + + async def _resume_pending_ask_user_question_stream( *, pipeline: Any, publisher: PipelineA2AEventPublisher, pending_input: dict[str, Any], prompt: str, + pipeline_input: PipelineUserInput, ) -> AsyncIterator[Any]: + pipeline_input = normalize_pipeline_user_input(pipeline_input) resume_ask_user_question = getattr(pipeline, "resume_ask_user_question", None) if not callable(resume_ask_user_question): raise RuntimeError("Pipeline cannot resume pending ask_user_question input.") @@ -1180,6 +1331,8 @@ async def _resume_pending_ask_user_question_stream( parameters = inspect.signature(resume_ask_user_question).parameters resume_kwargs: dict[str, Any] = {"tool_use_id": tool_use_id} + if pipeline_input.has_images: + resume_kwargs["supplemental_input"] = pipeline_input if "pending_input" in parameters or any( parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() ): @@ -1382,8 +1535,23 @@ def cancel_waiting_input_task_from_sidecar( ) if int(envelope.get("sequence") or 0) <= high_water_sequence: envelope["sequence"] = high_water_sequence + 1 + handoff_envelope = _waiting_input_cancel_handoff_event( + translator, + snapshot=snapshot, + cwd=cwd, + session_id=session_id, + pipeline_name=pipeline_name, + reason=reason, + ) + if handoff_envelope is not None and int(handoff_envelope.get("sequence") or 0) <= int( + envelope.get("sequence") or 0 + ): + handoff_envelope["sequence"] = int(envelope.get("sequence") or 0) + 1 try: - journal.append(envelope) + events_to_append = [envelope] + if handoff_envelope is not None: + events_to_append.append(handoff_envelope) + journal.append_many(events_to_append, durable=True) snapshot_store.save(reduce_pipeline_events(journal.read_all_repairing_tail())) except Exception: logger.warning("Failed to persist waiting A2A pipeline cancellation", exc_info=True) @@ -1391,6 +1559,106 @@ def cancel_waiting_input_task_from_sidecar( return True +def _waiting_input_cancel_handoff_event( + translator: PipelineEventTranslator, + *, + snapshot: dict[str, Any] | None, + cwd: str, + session_id: str, + pipeline_name: str, + reason: str, +) -> dict[str, Any] | None: + loaded_pipeline = _load_pipeline_definition_for_handoff(pipeline_name) + if loaded_pipeline is None: + return None + policy = getattr(loaded_pipeline, "on_complete", None) + if policy is None or policy.action != "switch_to_normal" or "canceled" not in policy.apply_on: + return None + + include_fields = getattr(policy.handoff_context, "include", []) + context_snapshot = _flat_pipeline_context_from_sidecar(cwd=cwd, session_id=session_id) + if not context_snapshot: + context_snapshot = _flat_pipeline_context_from_a2a_snapshot(snapshot, loaded_pipeline) + summary = build_handoff_summary( + pipeline_name=pipeline_name, + outcome="canceled", + context_snapshot=context_snapshot, + include_fields=include_fields, + ) + data: dict[str, Any] = { + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "canceled", + "summary": summary, + "reason": reason, + } + cleanup = _pipeline_cleanup_handoff_data_from_session(cwd=cwd, session_id=session_id, public_snapshot=snapshot) + if cleanup is not None: + data["cleanup"] = cleanup + return translator.manual_event( + "pipeline_handoff_ready", + "pipeline", + status="canceled", + data=data, + ) + + +def _load_pipeline_definition_for_handoff(pipeline_name: str) -> Any | None: + try: + pipeline_dir = discover_pipelines().get(pipeline_name) + if pipeline_dir is None: + return None + return load_pipeline_dir(pipeline_dir) + except Exception: + logger.warning("Failed to load A2A pipeline handoff policy for %s", pipeline_name, exc_info=True) + return None + + +def _flat_pipeline_context_from_sidecar(*, cwd: str, session_id: str) -> dict[str, Any]: + try: + restored = PipelineSession(SessionStorage().session_dir(cwd, session_id) / "pipeline").restore_sync() + except Exception: + logger.warning("Failed to load pipeline context for A2A cancel handoff", exc_info=True) + return {} + if not isinstance(restored, dict): + return {} + context_snapshot = restored.get("context_snapshot") + if not isinstance(context_snapshot, dict): + return {} + return _flatten_pipeline_context_snapshot(context_snapshot) + + +def _flat_pipeline_context_from_a2a_snapshot(snapshot: dict[str, Any] | None, loaded_pipeline: Any) -> dict[str, Any]: + if not isinstance(snapshot, dict): + return {} + field_by_step_id = { + str(getattr(step, "step_id")): str(getattr(step, "conclusion_field")) + for step in getattr(loaded_pipeline, "steps", []) + if getattr(step, "step_id", None) and getattr(step, "conclusion_field", None) + } + context: dict[str, Any] = {} + for step in snapshot.get("steps", []) if isinstance(snapshot.get("steps"), list) else []: + if not isinstance(step, dict): + continue + field_name = field_by_step_id.get(str(step.get("id") or "")) + if not field_name: + continue + conclusion = step.get("conclusion") + if conclusion is not None: + context[field_name] = conclusion + return context + + +def _flatten_pipeline_context_snapshot(snapshot: dict[str, Any]) -> dict[str, Any]: + flattened: dict[str, Any] = {} + for field_name, field_value in snapshot.items(): + if isinstance(field_value, dict) and "value" in field_value: + value = field_value.get("value") + if value is not None: + flattened[field_name] = value + return flattened + + def terminal_task_state_from_sidecar(*, cwd: str, session_id: str, context_id: str, task_id: str) -> str | None: pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) journal = A2APipelineJournal(pipeline_dir) @@ -1501,6 +1769,109 @@ def _persist_normal_handoff_summary(pipeline: Any, summary: str) -> None: logger.warning("Failed to persist A2A pipeline normal handoff summary", exc_info=True) +def _pipeline_cleanup_handoff_data(pipeline: Any) -> dict[str, Any] | None: + cleanup_ledger = getattr(pipeline, "cleanup_ledger", None) + if not callable(cleanup_ledger): + return None + try: + ledger = cleanup_ledger() + except Exception: + logger.warning("Failed to build A2A pipeline cleanup handoff data", exc_info=True) + return None + return _pipeline_cleanup_handoff_data_from_ledger(ledger) + + +def _pipeline_cleanup_handoff_data_from_session( + *, + cwd: str, + session_id: str, + public_snapshot: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + try: + ledger_path = SessionStorage().session_dir(cwd, session_id) / "pipeline" / "cleanup.yaml" + except Exception: + logger.warning("Failed to locate A2A pipeline cleanup ledger for handoff", exc_info=True) + return None + if not ledger_path.exists(): + snapshot_cleanup = public_snapshot.get("cleanup") if isinstance(public_snapshot, dict) else None + if _public_cleanup_snapshot_has_pending_evidence(snapshot_cleanup): + return _cleanup_state_unavailable_payload() + return None + return _pipeline_cleanup_handoff_data_from_ledger(CleanupLedger(ledger_path)) + + +def _pipeline_cleanup_handoff_data_from_ledger(ledger: Any) -> dict[str, Any] | None: + try: + ledger_path = getattr(ledger, "path", None) + if ledger_path is not None and not Path(ledger_path).exists(): + return _cleanup_state_unavailable_payload() + load_failed = getattr(ledger, "load_failed", None) + if callable(load_failed) and load_failed(): + return _cleanup_state_unavailable_payload() + build_pending_prompt = getattr(ledger, "build_pending_prompt", None) + if not callable(build_pending_prompt): + return None + prompt = build_pending_prompt() + except Exception: + logger.warning("Failed to build A2A pipeline cleanup handoff data", exc_info=True) + return _cleanup_state_unavailable_payload() + if prompt is None: + return None + + resources = list(getattr(prompt, "resources", []) or []) + if not resources: + return None + return { + "status": "pending", + "resourceCount": len(resources), + "statusMessage": str(getattr(prompt, "status_message", "") or ""), + "resources": [_cleanup_resource_handoff_data(resource) for resource in resources], + } + + +def _cleanup_state_unavailable_payload() -> dict[str, Any]: + return { + "status": "unavailable", + "statusMessage": _("Cleanup state unavailable. Inspect the session file and cloud resources manually."), + } + + +def _public_cleanup_snapshot_has_pending_evidence(cleanup: Any) -> bool: + if not isinstance(cleanup, dict): + return False + resources = cleanup.get("resources") + if isinstance(resources, list) and len(resources) > 0: + return True + resource_count = cleanup.get("resourceCount") + if isinstance(resource_count, int) and resource_count > 0: + return True + status = cleanup.get("status") + if isinstance(status, str) and status in {"pending", "started", "in_progress", "failed", "unavailable"}: + return True + return False + + +def _cleanup_resource_handoff_data(resource: Any) -> dict[str, Any]: + return { + "provider": str(getattr(resource, "provider", "") or ""), + "resourceType": str(getattr(resource, "resource_type", "") or ""), + "resourceId": str(getattr(resource, "resource_id", "") or ""), + "resourceName": str(getattr(resource, "resource_name", "") or ""), + "regionId": str(getattr(resource, "region_id", "") or ""), + "sourceStepId": str(getattr(resource, "source_step_id", "") or ""), + "cleanupStatus": str(getattr(resource, "cleanup_status", "") or ""), + "progressStatus": getattr(resource, "progress_status", None), + "lastError": _public_cleanup_error(getattr(resource, "last_error", None)), + } + + +def _public_cleanup_error(value: Any) -> str | None: + if not value: + return None + text = sanitize_public_text(value) + return text[:1000] + "..." if len(text) > 1000 else text + + async def _maybe_await(value: Any) -> Any: if inspect.isawaitable(value): return await value @@ -1727,6 +2098,22 @@ def _sidecar_matches_task( return False +def _active_sidecar_mismatch_error_from_publisher( + publisher: PipelineA2AEventPublisher, + *, + context_id: str, + sidecar_status: str, +) -> RecoverablePipelineInvalidParamsError: + owner = _current_sidecar_owner(publisher, context_id=context_id) + recoverable_task_id = owner.task_id if owner is not None and owner.task_id else "unknown" + recoverable_context_id = owner.context_id if owner is not None and owner.context_id else context_id + return _active_sidecar_mismatch_error( + recoverable_task_id=recoverable_task_id, + context_id=recoverable_context_id, + sidecar_status=sidecar_status, + ) + + def _current_sidecar_owner(publisher: PipelineA2AEventPublisher, *, context_id: str) -> _TaskContextOwner | None: return _current_sidecar_owner_from_stores( snapshot_store=publisher.snapshot_store, diff --git a/src/iac_code/a2a/pipeline_journal.py b/src/iac_code/a2a/pipeline_journal.py index 2a2586bf..201bc915 100644 --- a/src/iac_code/a2a/pipeline_journal.py +++ b/src/iac_code/a2a/pipeline_journal.py @@ -8,7 +8,11 @@ from pathlib import Path from typing import Any +from iac_code.utils.state_io import fsync_parent_dir + logger = logging.getLogger(__name__) +_EVENT_GROUP_RECORD_TYPE = "event_group" +_EVENT_GROUP_RECORD_KEY = "__iac_code_record_type" class A2APipelineJournalReadError(ValueError): @@ -20,8 +24,9 @@ def __init__(self, pipeline_dir: str | Path) -> None: self.pipeline_dir = Path(pipeline_dir) self.path = self.pipeline_dir / "a2a-events.jsonl" - def append(self, event: dict[str, Any]) -> None: + def append(self, event: dict[str, Any], durable: bool = False) -> None: self.pipeline_dir.mkdir(parents=True, exist_ok=True) + created = not self.path.exists() safe_event = to_json_safe(event) try: line = json.dumps(safe_event, ensure_ascii=False, separators=(",", ":"), allow_nan=False) @@ -31,6 +36,37 @@ def append(self, event: dict[str, Any]) -> None: with self.path.open("a", encoding="utf-8") as handle: handle.write(line + "\n") handle.flush() + if durable: + os.fsync(handle.fileno()) + if durable and created: + fsync_parent_dir(self.path) + + def append_many(self, events: list[dict[str, Any]], durable: bool = False) -> None: + if not events: + return + + self.pipeline_dir.mkdir(parents=True, exist_ok=True) + created = not self.path.exists() + safe_events = [] + for event in events: + safe_event = to_json_safe(event) + if not isinstance(safe_event, dict): + raise TypeError("A2A journal group events must be JSON objects") + safe_events.append(safe_event) + record = { + _EVENT_GROUP_RECORD_KEY: _EVENT_GROUP_RECORD_TYPE, + "schemaVersion": "1.0", + "groupId": uuid.uuid4().hex, + "events": safe_events, + } + line = json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + with self.path.open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + handle.flush() + if durable: + os.fsync(handle.fileno()) + if durable and created: + fsync_parent_dir(self.path) def read_all(self) -> list[dict[str, Any]]: return self._read_all(strict=False) @@ -116,7 +152,7 @@ def _read_all(self, *, strict: bool) -> list[dict[str, Any]]: f"Non-object A2A pipeline journal line {line_number} in {self.path}" ) continue - events.append(value) + events.extend(_events_from_journal_record(value, strict=strict, line_number=line_number, path=self.path)) events.sort(key=_sequence_value) return events @@ -133,6 +169,25 @@ def _sequence_value(event: dict[str, Any]) -> int: return 0 +def _events_from_journal_record( + value: dict[str, Any], + *, + strict: bool, + line_number: int, + path: Path, +) -> list[dict[str, Any]]: + if value.get(_EVENT_GROUP_RECORD_KEY) != _EVENT_GROUP_RECORD_TYPE: + return [value] + + group_events = value.get("events") + if not isinstance(group_events, list) or not all(isinstance(event, dict) for event in group_events): + if strict: + raise A2APipelineJournalReadError(f"Invalid A2A pipeline journal event group line {line_number} in {path}") + logger.warning("Skipping invalid A2A pipeline journal event group in %s", path) + return [] + return group_events + + def _repairable_tail_bytes(content: bytes) -> tuple[bytes, bytes] | None: if not content: return None diff --git a/src/iac_code/a2a/pipeline_recovery.py b/src/iac_code/a2a/pipeline_recovery.py index 99c984ba..a9789d2b 100644 --- a/src/iac_code/a2a/pipeline_recovery.py +++ b/src/iac_code/a2a/pipeline_recovery.py @@ -12,6 +12,7 @@ A2APipelineSnapshotStore, reduce_pipeline_events, sanitize_pipeline_artifact_uris, + sanitize_pipeline_cleanup_private_fields, ) from iac_code.i18n import _ @@ -44,9 +45,9 @@ async def get_state( snapshot_store = A2APipelineSnapshotStore(pipeline_dir) snapshot = snapshot_store.load() events = journal.read_all_repairing_tail() + context_events = _events_for_task(events, task_id=None, context_id=context_id) recovery_task_id = task_id if recovery_task_id is None: - context_events = _events_for_task(events, task_id=None, context_id=context_id) snapshot_task_id = None if isinstance(snapshot, dict) and _snapshot_matches_context(snapshot, context_id=context_id): snapshot_task_id = snapshot.get("taskId") @@ -88,21 +89,45 @@ async def get_state( snapshot_store.save(snapshot) snapshot = snapshot_store.load() or snapshot elif recovery_task_id is not None and ( - not _snapshot_matches( + not _snapshot_matches_or_delivery_alias( snapshot, task_id=recovery_task_id, context_id=context_id, + context_events=context_events, ) - or not _snapshot_seen_events_are_within_context_task( - snapshot, - _events_for_task(events, task_id=None, context_id=context_id), - task_id=recovery_task_id, + or ( + _snapshot_matches(snapshot, task_id=recovery_task_id, context_id=context_id) + and not _snapshot_seen_events_are_within_context_task( + snapshot, + context_events, + task_id=recovery_task_id, + ) + ) + or ( + not _snapshot_matches(snapshot, task_id=recovery_task_id, context_id=context_id) + and _snapshot_is_missing_delivery_alias_events( + snapshot, + task_id=recovery_task_id, + context_events=context_events, + ) ) ): - if not replay_events: + rebuild_events = _rebuild_events_for_recovery_task( + events, + snapshot=snapshot, + task_id=recovery_task_id, + context_id=context_id, + fallback_events=replay_events, + ) + if not rebuild_events: raise ValueError(_("A2A pipeline state not found")) - snapshot = reduce_pipeline_events(replay_events) - if not _snapshot_matches(snapshot, task_id=recovery_task_id, context_id=context_id): + snapshot = reduce_pipeline_events(rebuild_events) + if not _snapshot_matches_or_delivery_alias( + snapshot, + task_id=recovery_task_id, + context_id=context_id, + context_events=context_events, + ): raise ValueError(_("A2A pipeline state not found")) if task_id is None: snapshot_store.save(snapshot) @@ -132,7 +157,13 @@ async def get_state( snapshot = snapshot_store.load() or snapshot if task_id is not None and not _snapshot_matches(snapshot, task_id=task_id, context_id=context_id): - raise ValueError(_("A2A pipeline state not found")) + if not _snapshot_matches_or_delivery_alias( + snapshot, + task_id=task_id, + context_id=context_id, + context_events=context_events, + ): + raise ValueError(_("A2A pipeline state not found")) if ( task_id is None and recovery_task_id is not None @@ -149,8 +180,8 @@ async def get_state( replay_after = after_sequence if after_sequence is not None else _int_value(snapshot.get("lastSequence"), 0) events_after_replay = [event for event in replay_events if _int_value(event.get("sequence"), 0) > replay_after] return { - "snapshot": _json_compatible(sanitize_pipeline_artifact_uris(snapshot)), - "events": _json_compatible(sanitize_pipeline_artifact_uris(events_after_replay)), + "snapshot": _json_compatible(_sanitize_public_recovery_payload(snapshot)), + "events": _json_compatible(_sanitize_public_recovery_payload(events_after_replay)), } async def _verify_task_owner( @@ -182,6 +213,10 @@ def _json_compatible(value: Any) -> Any: return value +def _sanitize_public_recovery_payload(value: Any) -> Any: + return sanitize_pipeline_cleanup_private_fields(sanitize_pipeline_artifact_uris(value)) + + def _events_for_task( events: list[dict[str, Any]], *, @@ -191,13 +226,77 @@ def _events_for_task( context_events = [event for event in events if event.get("contextId") == context_id] if task_id is None: return context_events - return [event for event in context_events if event.get("taskId") == task_id] + return [ + event for event in context_events if event.get("taskId") == task_id or event.get("deliveryTaskId") == task_id + ] def _snapshot_matches(snapshot: dict[str, Any], *, task_id: str, context_id: str) -> bool: return snapshot.get("taskId") == task_id and snapshot.get("contextId") == context_id +def _snapshot_matches_or_delivery_alias( + snapshot: dict[str, Any], + *, + task_id: str, + context_id: str, + context_events: list[dict[str, Any]], +) -> bool: + if _snapshot_matches(snapshot, task_id=task_id, context_id=context_id): + return True + if not _snapshot_matches_context(snapshot, context_id=context_id): + return False + snapshot_task_id = snapshot.get("taskId") + if not isinstance(snapshot_task_id, str): + return False + return any( + event.get("taskId") == snapshot_task_id and event.get("deliveryTaskId") == task_id for event in context_events + ) + + +def _snapshot_is_missing_delivery_alias_events( + snapshot: dict[str, Any], + *, + task_id: str, + context_events: list[dict[str, Any]], +) -> bool: + snapshot_task_id = snapshot.get("taskId") + if not isinstance(snapshot_task_id, str): + return False + alias_events = [ + event + for event in context_events + if event.get("taskId") == snapshot_task_id and event.get("deliveryTaskId") == task_id + ] + if not alias_events: + return False + seen_event_ids = snapshot.get("seenEventIds") + if isinstance(seen_event_ids, list): + seen = {event_id for event_id in seen_event_ids if isinstance(event_id, str)} + return any(isinstance(event.get("eventId"), str) and event["eventId"] not in seen for event in alias_events) + snapshot_sequence = _int_value(snapshot.get("lastSequence"), 0) + return any(_int_value(event.get("sequence"), 0) > snapshot_sequence for event in alias_events) + + +def _rebuild_events_for_recovery_task( + events: list[dict[str, Any]], + *, + snapshot: dict[str, Any], + task_id: str, + context_id: str, + fallback_events: list[dict[str, Any]], +) -> list[dict[str, Any]]: + if _snapshot_matches(snapshot, task_id=task_id, context_id=context_id): + return fallback_events + snapshot_task_id = snapshot.get("taskId") + if not isinstance(snapshot_task_id, str): + return fallback_events + source_events = _events_for_task(events, task_id=snapshot_task_id, context_id=context_id) + if any(event.get("deliveryTaskId") == task_id for event in source_events): + return source_events + return fallback_events + + def _snapshot_matches_context(snapshot: dict[str, Any], *, context_id: str) -> bool: return snapshot.get("contextId") == context_id @@ -270,7 +369,15 @@ def _snapshot_seen_events_are_within_context_task( event_task_ids = { event.get("eventId"): event.get("taskId") for event in context_events if isinstance(event.get("eventId"), str) } + event_delivery_task_ids = { + event.get("eventId"): event.get("deliveryTaskId") + for event in context_events + if isinstance(event.get("eventId"), str) + } return all( - not isinstance(event_id, str) or event_id not in event_task_ids or event_task_ids[event_id] == task_id + not isinstance(event_id, str) + or event_id not in event_task_ids + or event_task_ids[event_id] == task_id + or event_delivery_task_ids.get(event_id) == task_id for event_id in seen_event_ids ) diff --git a/src/iac_code/a2a/pipeline_snapshot.py b/src/iac_code/a2a/pipeline_snapshot.py index edef7ad6..6d54b69e 100644 --- a/src/iac_code/a2a/pipeline_snapshot.py +++ b/src/iac_code/a2a/pipeline_snapshot.py @@ -3,8 +3,6 @@ import copy import json import logging -import os -import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -14,15 +12,41 @@ sanitize_public_tool_output_data, ) from iac_code.a2a.pipeline_journal import to_json_safe +from iac_code.pipeline.constants import ( + PIPELINE_EVENT_CLEANUP_COMPLETED, + PIPELINE_EVENT_CLEANUP_FAILED, + PIPELINE_EVENT_CLEANUP_PROGRESS, + PIPELINE_EVENT_CLEANUP_STARTED, +) +from iac_code.utils.public_errors import sanitize_public_text +from iac_code.utils.state_io import atomic_write_json SNAPSHOT_SCHEMA_VERSION = "1.1" logger = logging.getLogger(__name__) +_PUBLIC_TEXT_MAX_CHARS = 1000 _TERMINAL_STATUS_BY_EVENT_TYPE = { "pipeline_completed": "completed", "pipeline_failed": "failed", "pipeline_canceled": "canceled", } +_CLEANUP_STATUS_BY_EVENT_TYPE = { + PIPELINE_EVENT_CLEANUP_STARTED: "started", + PIPELINE_EVENT_CLEANUP_PROGRESS: "in_progress", + PIPELINE_EVENT_CLEANUP_COMPLETED: "completed", + PIPELINE_EVENT_CLEANUP_FAILED: "failed", +} +_KNOWN_CLEANUP_STATUSES = {"pending", "started", "in_progress", "completed", "failed", "skipped"} +_CLEANUP_ERROR_KEYS = { + "error", + "errorMessage", + "errorSummary", + "error_message", + "error_summary", + "lastError", + "last_error", +} +_PIPELINE_WARNING_PRIVATE_DATA_KEYS = {"ledger_path", "ledgerPath", "load_error", "loadError"} class A2APipelineSnapshotStore: @@ -32,29 +56,19 @@ def __init__(self, pipeline_dir: str | Path) -> None: def save(self, snapshot: dict[str, Any]) -> bool: previous = self.load() - next_snapshot = copy.deepcopy(snapshot) + next_snapshot = _sanitize_public_snapshot_private_cleanup_fields(snapshot) next_snapshot["snapshotVersion"] = _snapshot_version(previous) + 1 next_snapshot = to_json_safe(next_snapshot) if not isinstance(next_snapshot, dict): logger.warning("Skipping invalid A2A pipeline snapshot for %s", self.path) return False - self.pipeline_dir.mkdir(parents=True, exist_ok=True) - tmp_path = self.path.with_name(f"{self.path.name}.{uuid.uuid4().hex}.tmp") try: - with tmp_path.open("w", encoding="utf-8") as handle: - json.dump(next_snapshot, handle, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - tmp_path.replace(self.path) + atomic_write_json(self.path, next_snapshot, durable=True) return True except (OSError, TypeError, ValueError): logger.warning("Failed to persist A2A pipeline snapshot to %s", self.path, exc_info=True) return False - finally: - if tmp_path.exists(): - tmp_path.unlink() def load(self) -> dict[str, Any] | None: try: @@ -71,7 +85,7 @@ def load(self) -> dict[str, Any] | None: type(value).__name__, ) return None - return value + return _sanitize_public_snapshot_private_cleanup_fields(value) def reduce_pipeline_events( @@ -109,6 +123,25 @@ def sanitize_pipeline_artifact_uris(value: Any) -> Any: return sanitized +def sanitize_pipeline_cleanup_private_fields(value: Any) -> Any: + if isinstance(value, list): + return [sanitize_pipeline_cleanup_private_fields(item) for item in value] + if not isinstance(value, dict): + return value + + sanitized = _sanitize_cleanup_private_fields(value) + event_type = _string_or_none(sanitized.get("eventType")) or "" + if event_type in _CLEANUP_STATUS_BY_EVENT_TYPE or sanitized.get("scope") == "cleanup": + data = _dict_or_none(sanitized.get("data")) + if data is not None: + sanitized["data"] = _sanitize_cleanup_private_fields(data, root_is_cleanup=True) + elif event_type == "pipeline_warning": + data = _dict_or_none(sanitized.get("data")) + if data is not None: + sanitized["data"] = _pipeline_warning_public_data(data) + return sanitized + + class _PipelineSnapshotReducer: def __init__(self, existing_snapshot: dict[str, Any] | None = None) -> None: self._snapshot = _snapshot_from_existing(existing_snapshot) @@ -128,7 +161,9 @@ def __init__(self, existing_snapshot: dict[str, Any] | None = None) -> None: self._rollback_keys: set[str] = set() self._candidate_restart_keys: set[str] = set() self._handoff_history_keys: set[str] = set() + self._warning_history_keys: set[str] = set() self._stack_history_keys: set[str] = set() + self._cleanup_history_keys: set[str] = set() self._skip_sequences_through = 0 self._hydrate_existing_snapshot(existing_snapshot) @@ -178,7 +213,9 @@ def _hydrate_existing_snapshot(self, existing_snapshot: dict[str, Any] | None) - self._hydrate_rollbacks() self._hydrate_candidate_restarts() self._hydrate_control_history("handoffHistory", self._handoff_history_keys) + self._hydrate_control_history("warningHistory", self._warning_history_keys) self._hydrate_stack_history() + self._hydrate_cleanup_history() def _hydrate_steps(self) -> None: valid_steps: list[dict[str, Any]] = [] @@ -336,6 +373,24 @@ def _hydrate_stack_history(self) -> None: self._seen_event_ids.add(event_id) stacks["history"] = unique_history + def _hydrate_cleanup_history(self) -> None: + cleanup = self._snapshot["cleanup"] + unique_history: list[dict[str, Any]] = [] + for item in cleanup["history"]: + if not isinstance(item, dict): + continue + event_id = _string_or_none(item.get("eventId")) + key = event_id or str(_sequence_value(item)) + if key in self._cleanup_history_keys: + if event_id is not None: + self._seen_event_ids.add(event_id) + continue + self._cleanup_history_keys.add(key) + unique_history.append(item) + if event_id is not None: + self._seen_event_ids.add(event_id) + cleanup["history"] = unique_history + def _is_legacy_replay_event(self, event: dict[str, Any]) -> bool: return self._skip_sequences_through > 0 and _sequence_value(event) <= self._skip_sequences_through @@ -357,13 +412,22 @@ def _apply(self, event: dict[str, Any]) -> None: self._snapshot["lastSequence"] = max(self._snapshot["lastSequence"], _sequence_value(event)) self._merge_pipeline_identity(event) - data = _dict_or_empty(event.get("data")) + data = _sanitize_cleanup_private_fields(_dict_or_empty(event.get("data"))) if event_type == "pipeline_started": self._apply_pipeline_started(data) elif event_type == "pipeline_handoff_ready": handoff = _normal_handoff(event) self._snapshot["normalHandoff"] = handoff self._append_control_history("handoffHistory", self._handoff_history_keys, handoff) + cleanup_data = _dict_or_none(data.get("cleanup")) + if cleanup_data is not None: + self._apply_cleanup_data(cleanup_data, event) + elif event_type == "pipeline_warning": + self._append_control_history( + "warningHistory", + self._warning_history_keys, + _warning_history_entry(event), + ) step = self._upsert_step(event.get("step"), event) candidate = self._upsert_candidate(step, event.get("candidate"), event) @@ -396,6 +460,8 @@ def _apply(self, event: dict[str, Any]) -> None: self._upsert_tool_result_item(event) elif event_type == "stack_current_changed": self._apply_stack_current_changed(event) + elif event_type in _CLEANUP_STATUS_BY_EVENT_TYPE: + self._apply_cleanup_event(event) elif event_type == "rollback_completed": self._append_rollback(event) elif event_type == "candidate_restart_requested": @@ -412,7 +478,7 @@ def _apply(self, event: dict[str, Any]) -> None: self._snapshot["status"] = terminal_status self._snapshot["pendingInput"] = None self._snapshot["control"]["activeCandidateRunIds"] = [] - elif event_type not in {"input_required", "input_received"} and not ( + elif event_type not in {"input_required", "input_received", *_CLEANUP_STATUS_BY_EVENT_TYPE} and not ( event_type == "pipeline_handoff_ready" and self._snapshot["status"] in {"completed", "failed", "canceled"} ): self._apply_event_status(event) @@ -745,6 +811,82 @@ def _apply_stack_current_changed(self, event: dict[str, Any]) -> None: else: stacks["current"] = copy.deepcopy(existing) + def _apply_cleanup_event(self, event: dict[str, Any]) -> None: + data = _sanitize_cleanup_private_fields(copy.deepcopy(_dict_or_empty(event.get("data"))), root_is_cleanup=True) + event_type = _string_or_none(event.get("eventType")) or "" + data.setdefault("status", _CLEANUP_STATUS_BY_EVENT_TYPE.get(event_type, "pending")) + self._apply_cleanup_data(data, event) + + def _apply_cleanup_data(self, data: dict[str, Any], event: dict[str, Any]) -> None: + cleanup = self._snapshot["cleanup"] + status = _string_or_none(data.get("status")) + if status is not None: + cleanup["status"] = status + + resources = _dict_list(data.get("resources")) + if resources: + cleanup["resources"] = copy.deepcopy(resources) + else: + self._merge_cleanup_resource(cleanup, data) + + resource_count = _int_or_none(data.get("resourceCount")) + if resource_count is not None: + cleanup["resourceCount"] = resource_count + elif cleanup["resources"]: + cleanup["resourceCount"] = len(cleanup["resources"]) + cleanup["status"] = _aggregate_cleanup_status(cleanup["resources"], fallback=status or cleanup.get("status")) + + for key in ("statusMessage",): + if key in data: + cleanup[key] = copy.deepcopy(data[key]) + + key = _string_or_none(event.get("eventId")) or str(_sequence_value(event)) + if key in self._cleanup_history_keys: + return + self._cleanup_history_keys.add(key) + entry = { + "eventType": _string_or_none(event.get("eventType")), + "eventId": _string_or_none(event.get("eventId")), + "sequence": _sequence_value(event), + "createdAt": _string_or_none(event.get("createdAt")), + "scope": _string_or_none(event.get("scope")) or "cleanup", + "status": cleanup["status"], + "data": copy.deepcopy(data), + } + _merge_event_coordinates(entry, event) + cleanup["history"].append(entry) + + @staticmethod + def _merge_cleanup_resource(cleanup: dict[str, Any], data: dict[str, Any]) -> None: + resource_id = _string_or_none(data.get("resourceId")) + if resource_id is None: + return + provider = _string_or_none(data.get("provider")) + resource_type = _string_or_none(data.get("resourceType")) or _string_or_none(data.get("resource_type")) + region_id = _string_or_none(data.get("regionId")) + resources = cleanup["resources"] + + def optional_field_matches(resource: dict[str, Any], *keys: str, incoming: str | None) -> bool: + existing = None + for key in keys: + existing = _string_or_none(resource.get(key)) + if existing is not None: + break + return incoming is None or existing is None or existing == incoming + + for resource in resources: + if not isinstance(resource, dict): + continue + if ( + resource.get("resourceId") == resource_id + and optional_field_matches(resource, "provider", incoming=provider) + and optional_field_matches(resource, "resourceType", "resource_type", incoming=resource_type) + and optional_field_matches(resource, "regionId", incoming=region_id) + ): + resource.update(copy.deepcopy(data)) + return + resources.append(copy.deepcopy(data)) + def _upsert_display_record( self, display_key: str, @@ -832,7 +974,7 @@ def _pending_input(self, event: dict[str, Any]) -> dict[str, Any]: def _normal_handoff(event: dict[str, Any]) -> dict[str, Any]: - data = copy.deepcopy(_dict_or_empty(event.get("data"))) + data = _sanitize_cleanup_private_fields(copy.deepcopy(_dict_or_empty(event.get("data")))) handoff = { "eventType": _string_or_none(event.get("eventType")), "eventId": _string_or_none(event.get("eventId")), @@ -849,6 +991,23 @@ def _normal_handoff(event: dict[str, Any]) -> dict[str, Any]: return handoff +def _warning_history_entry(event: dict[str, Any]) -> dict[str, Any]: + entry = { + "eventId": _string_or_none(event.get("eventId")), + "sequence": _sequence_value(event), + "createdAt": _string_or_none(event.get("createdAt")), + "data": _pipeline_warning_public_data(_dict_or_empty(event.get("data"))), + } + _merge_event_coordinates(entry, event) + return entry + + +def _pipeline_warning_public_data(data: dict[str, Any]) -> dict[str, Any]: + return copy.deepcopy( + {key: value for key, value in data.items() if str(key) not in _PIPELINE_WARNING_PRIVATE_DATA_KEYS} + ) + + def _interaction_history_entry(event: dict[str, Any]) -> dict[str, Any]: data = copy.deepcopy(_dict_or_empty(event.get("data"))) input_value = copy.deepcopy(_dict_or_empty(event.get("input"))) @@ -924,6 +1083,12 @@ def _empty_snapshot() -> dict[str, Any]: "byId": {}, "history": [], }, + "cleanup": { + "status": "none", + "resourceCount": 0, + "resources": [], + "history": [], + }, "normalHandoff": None, "pendingInput": None, "control": { @@ -933,11 +1098,38 @@ def _empty_snapshot() -> dict[str, Any]: "rollbackHistory": [], "candidateRestarts": [], "handoffHistory": [], + "warningHistory": [], }, "seenEventIds": [], } +def _cleanup_resource_status(resource: dict[str, Any]) -> str | None: + status = ( + _string_or_none(resource.get("cleanupStatus")) + or _string_or_none(resource.get("cleanup_status")) + or _string_or_none(resource.get("status")) + ) + return status if status in _KNOWN_CLEANUP_STATUSES else None + + +def _aggregate_cleanup_status(resources: list[dict[str, Any]], *, fallback: Any = None) -> str: + fallback_status = _string_or_none(fallback) or "none" + statuses = [_cleanup_resource_status(resource) for resource in resources if isinstance(resource, dict)] + statuses = [status for status in statuses if status is not None] + if not statuses: + return fallback_status + if "failed" in statuses: + return "failed" + if "in_progress" in statuses: + return "in_progress" + if "started" in statuses: + return "started" + if "pending" in statuses: + return "pending" + return "completed" + + def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[str, Any]: if not isinstance(existing_snapshot, dict): return _empty_snapshot() @@ -969,9 +1161,27 @@ def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[st else {}, "history": _dict_list(stacks.get("history")), } + normal_handoff = snapshot.get("normalHandoff") snapshot["normalHandoff"] = ( - copy.deepcopy(snapshot.get("normalHandoff")) if isinstance(snapshot.get("normalHandoff"), dict) else None + _sanitize_cleanup_private_fields(normal_handoff) if isinstance(normal_handoff, dict) else None ) + cleanup = snapshot.get("cleanup") + if not isinstance(cleanup, dict): + cleanup = {} + cleanup = _sanitize_cleanup_private_fields(cleanup, root_is_cleanup=True) + cleanup_resources = _dict_list(cleanup.get("resources")) + cleanup_count = _int_or_none(cleanup.get("resourceCount")) + if cleanup_count is None: + cleanup_count = len(cleanup_resources) + snapshot["cleanup"] = { + "status": _string_or_none(cleanup.get("status")) or "none", + "resourceCount": cleanup_count, + "resources": cleanup_resources, + "history": _dict_list(cleanup.get("history")), + } + for key in ("statusMessage",): + if key in cleanup: + snapshot["cleanup"][key] = copy.deepcopy(cleanup[key]) control = snapshot.get("control") if not isinstance(control, dict): @@ -984,6 +1194,7 @@ def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[st "rollbackHistory", "candidateRestarts", "handoffHistory", + "warningHistory", ): value = snapshot["control"].get(key) snapshot["control"][key] = copy.deepcopy(value) if isinstance(value, list) else [] @@ -998,6 +1209,71 @@ def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[st return snapshot +def _sanitize_public_snapshot_private_cleanup_fields(value: dict[str, Any]) -> dict[str, Any]: + sanitized = copy.deepcopy(value) + normal_handoff = sanitized.get("normalHandoff") + if isinstance(normal_handoff, dict): + sanitized["normalHandoff"] = _sanitize_cleanup_private_fields(normal_handoff) + cleanup = sanitized.get("cleanup") + if isinstance(cleanup, dict): + sanitized["cleanup"] = _sanitize_cleanup_private_fields(cleanup, root_is_cleanup=True) + control = sanitized.get("control") + if isinstance(control, dict): + handoff_history = control.get("handoffHistory") + if isinstance(handoff_history, list): + control["handoffHistory"] = [ + _sanitize_cleanup_private_fields(item) if isinstance(item, dict) else item for item in handoff_history + ] + warning_history = control.get("warningHistory") + if isinstance(warning_history, list): + control["warningHistory"] = [ + _sanitize_pipeline_warning_history(item) if isinstance(item, dict) else item for item in warning_history + ] + return sanitized + + +def _sanitize_pipeline_warning_history(item: dict[str, Any]) -> dict[str, Any]: + sanitized = copy.deepcopy(item) + data = _dict_or_none(sanitized.get("data")) + if data is not None: + sanitized["data"] = _pipeline_warning_public_data(data) + return sanitized + + +def _sanitize_cleanup_private_fields(value: dict[str, Any], *, root_is_cleanup: bool = False) -> dict[str, Any]: + sanitized = copy.deepcopy(value) + _drop_cleanup_private_fields(sanitized, inside_cleanup=root_is_cleanup) + return sanitized + + +def _drop_cleanup_private_fields(value: Any, *, inside_cleanup: bool) -> None: + if isinstance(value, dict): + if inside_cleanup: + for key in ("prompt", "ledgerPath", "ledger_path"): + value.pop(key, None) + for key in _CLEANUP_ERROR_KEYS & value.keys(): + value[key] = _sanitize_cleanup_error_value(value[key]) + for item in value.values(): + _drop_cleanup_private_fields(item, inside_cleanup=inside_cleanup) + cleanup = value.get("cleanup") + if cleanup is not None: + _drop_cleanup_private_fields(cleanup, inside_cleanup=True) + elif isinstance(value, list): + for item in value: + _drop_cleanup_private_fields(item, inside_cleanup=inside_cleanup) + + +def _sanitize_cleanup_error_value(value: Any) -> Any: + if isinstance(value, str): + text = sanitize_public_text(value) + return text[:_PUBLIC_TEXT_MAX_CHARS] + "..." if len(text) > _PUBLIC_TEXT_MAX_CHARS else text + if isinstance(value, dict): + return {key: _sanitize_cleanup_error_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_sanitize_cleanup_error_value(item) for item in value] + return value + + def _merge_coordinate(target: dict[str, Any], coordinate: dict[str, Any]) -> None: for key, value in coordinate.items(): if value is not None: @@ -1212,4 +1488,9 @@ def _utc_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") -__all__ = ["A2APipelineSnapshotStore", "SNAPSHOT_SCHEMA_VERSION", "reduce_pipeline_events"] +__all__ = [ + "A2APipelineSnapshotStore", + "SNAPSHOT_SCHEMA_VERSION", + "reduce_pipeline_events", + "sanitize_pipeline_cleanup_private_fields", +] diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index 0a1d5356..8ad950fd 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -15,10 +15,53 @@ from iac_code.a2a.pipeline_events import PipelineEventTranslator, safe_permission_metadata from iac_code.a2a.pipeline_journal import A2APipelineJournal, to_json_safe from iac_code.a2a.pipeline_snapshot import SNAPSHOT_SCHEMA_VERSION, A2APipelineSnapshotStore, reduce_pipeline_events +from iac_code.pipeline.constants import ( + PIPELINE_EVENT_CLEANUP_COMPLETED, + PIPELINE_EVENT_CLEANUP_FAILED, + PIPELINE_EVENT_CLEANUP_PROGRESS, + PIPELINE_EVENT_CLEANUP_STARTED, +) from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent, ToolResultEvent PipelinePermissionResolver = Callable[[PermissionRequestEvent], bool | Awaitable[bool]] logger = logging.getLogger(__name__) +_RECOVERY_SEMANTIC_EVENT_TYPES = { + "pipeline_started", + "pipeline_resumed", + "step_started", + "step_completed", + "step_failed", + "candidate_started", + "candidate_selected", + "candidate_completed", + "candidate_failed", + "candidate_step_started", + "candidate_step_completed", + "candidate_step_failed", + "input_required", + "input_received", + "pipeline_completed", + "pipeline_failed", + "pipeline_canceled", + "pipeline_handoff_ready", + "pipeline_warning", + PIPELINE_EVENT_CLEANUP_STARTED, + PIPELINE_EVENT_CLEANUP_PROGRESS, + PIPELINE_EVENT_CLEANUP_COMPLETED, + PIPELINE_EVENT_CLEANUP_FAILED, + "artifact_created", + "rollback_completed", + "candidate_restart_requested", +} +_DISPLAY_ONLY_EVENT_TYPES = { + "candidate_detail_shown", + "diagram_shown", + "permission_requested", + "text_delta", + "tool_result", +} +_RECOVERY_STATE_SCOPES = {"step", "candidate", "candidateStep", "candidate_step"} +_RECOVERY_STATE_STATUSES = {"working"} class _SnapshotCatchUpUnavailableError(Exception): @@ -38,6 +81,8 @@ def __init__( snapshot_store: A2APipelineSnapshotStore, artifact_store: Any | None = None, exposure_types: Any = None, + delivery_task_id: str | None = None, + delivery_context_id: str | None = None, ) -> None: self.event_queue = event_queue self.translator = translator @@ -45,6 +90,8 @@ def __init__( self.snapshot_store = snapshot_store self.artifact_store = artifact_store self.exposure_types = normalize_a2a_exposure_types(exposure_types) + self.delivery_task_id = delivery_task_id + self.delivery_context_id = delivery_context_id self._sequence_lock = asyncio.Lock() self._last_sequence = 0 self.last_envelope: dict[str, Any] | None = None @@ -196,6 +243,7 @@ async def publish_manual( status: str = "working", data: dict[str, Any] | None = None, coordinates: dict[str, Any] | None = None, + require_durable_metadata: bool = False, ) -> dict[str, Any] | None: envelope = self.translator.manual_event(event_type, scope, status=status, data=data) if coordinates: @@ -203,7 +251,11 @@ async def publish_manual( value = coordinates.get(key) if isinstance(value, dict): envelope[key] = dict(value) - return envelope if await self._persist_and_enqueue(envelope) else None + return ( + envelope + if await self._persist_and_enqueue(envelope, require_durable_metadata=require_durable_metadata) + else None + ) def _next_snapshot(self, envelope: dict[str, Any]) -> dict[str, Any]: existing_snapshot = self.snapshot_store.load() @@ -244,6 +296,7 @@ async def _persist_and_enqueue( require_durable_metadata: bool = False, ) -> bool: async with self._sequence_lock: + self._annotate_delivery_alias(envelope) try: self._ensure_monotonic_sequence(envelope) except _SequenceHighWaterUnavailableError: @@ -253,10 +306,11 @@ async def _persist_and_enqueue( if not isinstance(safe_envelope, dict): logger.warning("Skipping invalid A2A pipeline envelope: %r", envelope) return False + durable_required = require_durable_metadata or is_recovery_semantic_event(safe_envelope) journal_persisted = False snapshot_persisted = False try: - self.journal.append(safe_envelope) + self.journal.append(safe_envelope, durable=durable_required) journal_persisted = True except Exception: logger.warning("Failed to append A2A pipeline journal event", exc_info=True) @@ -269,7 +323,7 @@ async def _persist_and_enqueue( logger.warning("Failed to persist A2A pipeline snapshot", exc_info=True) if snapshot_persisted: _maybe_inject_test_fault("after_a2a_pipeline_snapshot_saved") - if require_durable_metadata and not (journal_persisted or snapshot_persisted): + if durable_required and not (journal_persisted or snapshot_persisted): logger.warning("Skipping A2A pipeline status update because durable metadata was not persisted") return False if artifact_metadata is not None and not (journal_persisted or snapshot_persisted): @@ -281,6 +335,14 @@ async def _persist_and_enqueue( self.last_envelope = safe_envelope return True + def _annotate_delivery_alias(self, envelope: dict[str, Any]) -> None: + delivery_task_id = self._delivery_task_id(envelope) + delivery_context_id = self._delivery_context_id(envelope) + if delivery_task_id != str(envelope.get("taskId")): + envelope["deliveryTaskId"] = delivery_task_id + if delivery_context_id != str(envelope.get("contextId")): + envelope["deliveryContextId"] = delivery_context_id + def _ensure_monotonic_sequence(self, envelope: dict[str, Any]) -> None: current = _int_value(envelope.get("sequence"), 0) previous = self._last_persisted_sequence() @@ -359,8 +421,8 @@ async def _maybe_externalize_artifact( async def _enqueue_artifact_update(self, envelope: dict[str, Any], artifact_metadata: dict[str, Any]) -> None: await self.event_queue.enqueue_event( _artifact_update_event( - task_id=str(envelope["taskId"]), - context_id=str(envelope["contextId"]), + task_id=self._delivery_task_id(envelope), + context_id=self._delivery_context_id(envelope), metadata=artifact_metadata, ) ) @@ -391,17 +453,25 @@ async def _apply_permission_metadata( return approved async def _enqueue_status(self, envelope: dict[str, Any]) -> None: + task_id = self._delivery_task_id(envelope) + context_id = self._delivery_context_id(envelope) update = TaskStatusUpdateEvent( - task_id=str(envelope["taskId"]), - context_id=str(envelope["contextId"]), + task_id=task_id, + context_id=context_id, status=TaskStatus( state=_a2a_task_state_name(envelope), - message=_message_for_envelope(envelope), + message=_message_for_envelope(envelope, task_id=task_id, context_id=context_id), ), ) ParseDict({"iac_code": {"pipeline": envelope}}, update.metadata) await self.event_queue.enqueue_event(update) + def _delivery_task_id(self, envelope: dict[str, Any]) -> str: + return self.delivery_task_id or str(envelope["taskId"]) + + def _delivery_context_id(self, envelope: dict[str, Any]) -> str: + return self.delivery_context_id or str(envelope["contextId"]) + def _permission_request_from(event: Any) -> PermissionRequestEvent | None: inner = event.inner if isinstance(event, SubPipelineStreamEvent) else event @@ -418,6 +488,22 @@ def _resolve_permission_future(request: PermissionRequestEvent, approved: bool) request.response_future.set_result(approved) +def is_recovery_semantic_event(envelope: dict[str, Any]) -> bool: + event_type = envelope.get("eventType") + event_type = event_type if isinstance(event_type, str) else None + if event_type in _DISPLAY_ONLY_EVENT_TYPES: + return False + if event_type in _RECOVERY_SEMANTIC_EVENT_TYPES: + return True + status = envelope.get("status") + status = status if isinstance(status, str) else None + if status in {"waiting_input", "input_required", "completed", "failed", "canceled"}: + return True + scope = envelope.get("scope") + scope = scope if isinstance(scope, str) else None + return scope in _RECOVERY_STATE_SCOPES and status in _RECOVERY_STATE_STATUSES + + def _should_skip_envelope(envelope: dict[str, Any]) -> bool: return envelope.get("eventType") == "text_delta" and _text_from_envelope(envelope) == "" @@ -433,14 +519,20 @@ def _maybe_inject_test_fault(point: str) -> None: os._exit(97) -def _message_for_envelope(envelope: dict[str, Any]) -> Message | None: +def _message_for_envelope( + envelope: dict[str, Any], + *, + task_id: str | None = None, + context_id: str | None = None, +) -> Message | None: if envelope.get("eventType") != "text_delta": return None + message_task_id = task_id or str(envelope["taskId"]) return Message( - message_id=f"{envelope['taskId']}-pipeline-{envelope['sequence']}", - task_id=str(envelope["taskId"]), - context_id=str(envelope["contextId"]), + message_id=f"{message_task_id}-pipeline-{envelope['sequence']}", + task_id=message_task_id, + context_id=context_id or str(envelope["contextId"]), role=Role.ROLE_AGENT, parts=[make_text_part(_text_from_envelope(envelope))], ) @@ -488,4 +580,4 @@ def _int_value(value: Any, default: int) -> int: return default -__all__ = ["PipelineA2AEventPublisher", "PipelinePermissionResolver"] +__all__ = ["PipelineA2AEventPublisher", "PipelinePermissionResolver", "is_recovery_semantic_event"] diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index 23a993e0..714c8079 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -49,6 +49,10 @@ from iac_code.a2a.events import make_text_part from iac_code.a2a.executor import IacCodeA2AExecutor from iac_code.a2a.exposure import normalize_a2a_exposure_types +from iac_code.a2a.jsonrpc_passthrough import ( + install_jsonrpc_error_data_passthrough, + install_v03_jsonrpc_error_data_passthrough, +) from iac_code.a2a.metrics import NoOpA2AMetrics from iac_code.a2a.persistence import A2APersistenceStore from iac_code.a2a.pipeline_executor import ( @@ -237,11 +241,13 @@ async def on_list_tasks(self, params: ListTasksRequest, context): async def on_message_send(self, params: SendMessageRequest, context): self._validate_extensions(context) + self._validate_pipeline_message_request(params) await self._hydrate_recoverable_pipeline_task_id(params) return await super().on_message_send(params, context) async def on_message_send_stream(self, params: SendMessageRequest, context): self._validate_extensions(context) + self._validate_pipeline_message_request(params) await self._hydrate_recoverable_pipeline_task_id(params) task_id = params.message.task_id or None if task_id and isinstance(self.task_store, A2ATaskStore) and await self.task_store.is_task_active(task_id): @@ -495,6 +501,13 @@ async def on_delete_task_push_notification_config( self._validate_extensions(context) await super().on_delete_task_push_notification_config(params, context) + def _validate_pipeline_message_request(self, params: SendMessageRequest) -> None: + if get_run_mode() != RunMode.PIPELINE: + return + executor = getattr(self, "agent_executor", None) + if isinstance(executor, IacCodeA2AExecutor): + executor.validate_pipeline_message_request(params.message) + def _validate_extensions(self, context) -> None: requested = set(getattr(context, "requested_extensions", set()) or set()) required = sorted(extension.uri for extension in self._agent_card.capabilities.extensions if extension.required) @@ -511,7 +524,9 @@ def _task_is_input_required(task: Task) -> bool: def _create_dispatch_app(handler: DefaultRequestHandler) -> Starlette: + install_jsonrpc_error_data_passthrough() jsonrpc_endpoint = create_jsonrpc_routes(handler, rpc_url="/", enable_v0_3_compat=True)[0].endpoint + install_v03_jsonrpc_error_data_passthrough(jsonrpc_endpoint) async def handle_jsonrpc(request): await normalize_v03_jsonrpc_version(request) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 9550dbe6..89b73ccd 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -9,7 +9,7 @@ from collections import deque from collections.abc import AsyncGenerator, Callable from contextlib import suppress -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any, Literal from loguru import logger @@ -73,6 +73,26 @@ def _normalize_memory_filename(filename: Any) -> str: return name +def _extend_unique(target: list[str], values: list[str]) -> None: + seen = set(target) + for value in values: + if value not in seen: + target.append(value) + seen.add(value) + + +def _with_trusted_read_directories(permission_context: Any, directories: list[str]) -> Any: + if not directories: + return permission_context + + trusted_read_directories = list(getattr(permission_context, "trusted_read_directories", [])) + original_count = len(trusted_read_directories) + _extend_unique(trusted_read_directories, directories) + if len(trusted_read_directories) == original_count: + return permission_context + return replace(permission_context, trusted_read_directories=trusted_read_directories) + + def _filter_recalled_memory_content(content: str, selected_files: list[str]) -> str: keep = [_normalize_memory_filename(filename) for filename in selected_files] keep = [filename for filename in keep if filename] @@ -129,6 +149,9 @@ def __init__( memory_recall_service: Any = None, system_prompt_refresher: Callable[[], str] | None = None, pause_event: asyncio.Event | None = None, + tool_context_trusted_read_directories: list[str] | None = None, + tool_context_relative_read_directories: list[str] | None = None, + pipeline_mode: bool = False, ) -> None: self._provider_manager = provider_manager self.system_prompt = system_prompt @@ -141,6 +164,9 @@ def __init__( self._session_usage_totals = self._session_usage_store.load(self._cwd, self._session_id) self._permission_context = permission_context self._permission_context_getter = permission_context_getter + self._tool_context_trusted_read_directories = list(tool_context_trusted_read_directories or []) + self._tool_context_relative_read_directories = list(tool_context_relative_read_directories or []) + self._pipeline_mode = pipeline_mode self._auto_trigger_skills = auto_trigger_skills or [] self._auto_loaded_skills: set[str] = set() self._current_git_branch: str | None = None @@ -167,7 +193,7 @@ def __init__( self._result_storage = ResultStorage( storage_dir=os.path.join(str(get_config_dir()), "tool-results", self._session_id), ) - self._pending_injections: deque[str] = deque() + self._pending_injections: deque[str | list[ContentBlock]] = deque() self._current_turn_text: str = "" self._accepting_injected_user_messages = False self._pause_event = pause_event @@ -176,7 +202,7 @@ def __init__( def current_turn_text(self) -> str: return self._current_turn_text - def inject_user_message(self, msg: str) -> None: + def inject_user_message(self, msg: str | list[ContentBlock]) -> None: """Schedule a user message to be injected before the next LLM turn.""" self._pending_injections.append(msg) @@ -185,13 +211,27 @@ def can_accept_injected_user_message(self) -> bool: """Whether a queued supplement can still be consumed by this run.""" return self._accepting_injected_user_messages - def try_inject_user_message(self, msg: str) -> bool: + def try_inject_user_message(self, msg: str | list[ContentBlock]) -> bool: """Queue a supplement only when this loop still has a consumable turn.""" if not self.can_accept_injected_user_message: return False self.inject_user_message(msg) return True + def _drain_pending_injections(self) -> None: + while self._pending_injections: + injected = self._pending_injections.popleft() + self.context_manager.add_user_message(injected) + if self._session_storage: + from iac_code.agent.message import Message + + self._session_storage.append( + self._cwd, + self._session_id, + Message(role="user", content=injected), + git_branch=self._current_git_branch, + ) + def set_provider(self, provider_manager: Any, system_prompt: str | None = None) -> None: """Swap the provider manager in place, preserving conversation history. @@ -347,6 +387,7 @@ def _persist_context_messages(self) -> None: self._session_id, self.context_manager.get_messages(), git_branch=self._current_git_branch, + preserve_cleanup_prompts=True, ) def _inject_recalled_memory_result(self, result: Any) -> bool: @@ -765,8 +806,7 @@ async def _run_streaming_inner( # inject supplemental user text before the next provider call. if self._pause_event is not None: await self._pause_event.wait() - while self._pending_injections: - self.context_manager.add_user_message(self._pending_injections.popleft()) + self._drain_pending_injections() self._accepting_injected_user_messages = False self._current_turn_text = "" @@ -900,7 +940,12 @@ async def _run_streaming_inner( event_queue=queue, ) ) - context = ToolContext(cwd=self._cwd) + context = ToolContext( + cwd=self._cwd, + trusted_read_directories=list(self._tool_context_trusted_read_directories), + relative_read_directories=list(self._tool_context_relative_read_directories), + pipeline_mode=self._pipeline_mode, + ) allowed_requests: list[ToolCallRequest] = [] denied_results: list[tuple[ToolCallRequest, ToolResult]] = [] @@ -919,7 +964,14 @@ async def _run_streaming_inner( if perm_ctx is not None: from iac_code.services.permissions.pipeline import check_tool_permission - permission = await check_tool_permission(tool, request.input, perm_ctx) + effective_perm_ctx = _with_trusted_read_directories( + perm_ctx, self._tool_context_trusted_read_directories + ) + _extend_unique(context.additional_directories, list(effective_perm_ctx.additional_directories)) + _extend_unique( + context.trusted_read_directories, list(effective_perm_ctx.trusted_read_directories) + ) + permission = await check_tool_permission(tool, request.input, effective_perm_ctx) else: permission = await tool.check_permissions(request.input, {"cwd": context.cwd}) @@ -1271,6 +1323,7 @@ def stamp_last_turn_elapsed(self, elapsed: float) -> None: self._session_id, msgs, git_branch=self._current_git_branch, + preserve_cleanup_prompts=True, ) break diff --git a/src/iac_code/agent/message.py b/src/iac_code/agent/message.py index 08547cc3..7a2b0eea 100644 --- a/src/iac_code/agent/message.py +++ b/src/iac_code/agent/message.py @@ -48,6 +48,7 @@ class ImageBlock(BaseModel): type: Literal["image"] = "image" media_type: str # 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' data: str # base64 + ref_id: int | None = None # Union type for all content blocks diff --git a/src/iac_code/commands/prompt.py b/src/iac_code/commands/prompt.py index 9089dbcd..24ab7f1c 100644 --- a/src/iac_code/commands/prompt.py +++ b/src/iac_code/commands/prompt.py @@ -14,6 +14,7 @@ from typing import Any, cast from iac_code.agent.message import RECALLED_MEMORY_MARKER +from iac_code.agent.message import Message as AgentMessage from iac_code.agent.system_prompt import DYNAMIC_BOUNDARY from iac_code.i18n import _ from iac_code.utils.file_security import ensure_private_file @@ -83,6 +84,14 @@ def build_prompt_snapshot(repl: object) -> dict[str, Any]: if "provider_messages" in last_request else _provider_messages(agent_loop) ) + cleanup_messages = _cleanup_prompt_messages(repl, agent_loop) + provider_messages = _with_cleanup_prompt_messages( + repl, + agent_loop, + provider_messages, + cleanup_messages=cleanup_messages, + ) + cleanup_prompts = _cleanup_prompt_snapshots(cleanup_messages) tools = list(last_request.get("tools") or []) if "tools" in last_request else _tool_definitions(agent_loop) status = _status_snapshot(repl) metadata = { @@ -98,12 +107,22 @@ def build_prompt_snapshot(repl: object) -> dict[str, Any]: "system_prompt": system_prompt, "system_sections": _split_system_prompt(system_prompt), "provider_messages": provider_messages, + "cleanup_prompts": cleanup_prompts, "tools": tools, "memory_sections": _memory_sections(repl), } def _pipeline_prompt_snapshot(repl: object) -> dict[str, Any] | None: + runtime_getter = getattr(repl, "_get_runtime_mode", None) + if callable(runtime_getter): + try: + runtime_mode = runtime_getter() + except Exception: + runtime_mode = None + if str(getattr(runtime_mode, "value", runtime_mode)) != "pipeline": + return None + pipeline = getattr(repl, "_pipeline", None) get_prompt_contexts = getattr(pipeline, "get_prompt_contexts", None) if not callable(get_prompt_contexts): @@ -220,6 +239,14 @@ def render_prompt_html(snapshot: dict[str, Any]) -> str: provider_messages = "\n".join( _message_card(index, message) for index, message in enumerate(snapshot.get("provider_messages", []), start=1) ) + cleanup_messages = list(snapshot.get("cleanup_prompts") or []) + if not cleanup_messages: + cleanup_messages = [ + message for message in snapshot.get("provider_messages", []) if _is_cleanup_prompt_snapshot(message) + ] + cleanup_prompts = "\n".join( + _message_card(index, message) for index, message in enumerate(cleanup_messages, start=1) + ) tools = "\n".join(_tool_card(tool) for tool in snapshot.get("tools", [])) raw_system_prompt = _content_card( _("Raw Full System Prompt"), @@ -232,7 +259,10 @@ def render_prompt_html(snapshot: dict[str, Any]) -> str: raw_system_prompt=raw_system_prompt, ) messages_tab = provider_messages or '

{}

'.format(escape(_("No provider messages yet."))) + cleanup_tab = cleanup_prompts or '

{}

'.format(escape(_("No cleanup prompts in this snapshot."))) tools_tab = tools or '

{}

'.format(escape(_("No tools are currently registered."))) + cleanup_tab_button = _tab_button("cleanup", _("Cleanup Prompts")) if cleanup_messages else "" + cleanup_panel = _tab_panel("cleanup", cleanup_tab) if cleanup_messages else "" return """ @@ -431,11 +461,13 @@ def render_prompt_html(snapshot: dict[str, Any]) -> str: {all_tab_button} {system_tab_button} {messages_tab_button} + {cleanup_tab_button} {tools_tab_button} {all_panel} {system_panel} {messages_panel} + {cleanup_panel} {tools_panel} ")] + + assert "function isTerminalPipelineTaskState" in script + assert "isTerminalPipelineTaskState(state.status)" in script + + def extract_function(name: str) -> str: + start = script.index(f"function {name}") + brace = script.index("{", start) + depth = 0 + for index in range(brace, len(script)): + char = script[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return script[start : index + 1] + raise AssertionError(f"Could not extract {name}") + + functions = [extract_function("streamTaskIdForControls")] + if "function isTerminalPipelineTaskState" in script: + functions.insert(0, extract_function("isTerminalPipelineTaskState")) + + js_path = tmp_path / "stream-task-routing.js" + js_path.write_text( + "\n".join( + [ + 'const state = {normalHandoffReady: false, activeTaskId: "", taskId: "task-1", status: ""};', + *functions, + 'state.status = "TASK_STATE_CANCELED";', + 'if (streamTaskIdForControls({activeTaskId: "", taskId: "task-1"}) !== "") {', + ' throw new Error("canceled pipeline taskId should not be reused");', + "}", + 'state.status = "canceled";', + 'if (streamTaskIdForControls({activeTaskId: "", taskId: "task-1"}) !== "") {', + ' throw new Error("snapshot canceled pipeline taskId should not be reused");', + "}", + 'state.status = "waiting_input";', + 'if (streamTaskIdForControls({activeTaskId: "", taskId: "task-1"}) !== "task-1") {', + ' throw new Error("waiting input pipeline taskId should be reused");', + "}", + ] + ), + encoding="utf-8", + ) + + try: + result = subprocess.run(["node", str(js_path)], capture_output=True, text=True, check=False) + except FileNotFoundError: + pytest.skip("node is not installed") + + assert result.returncode == 0, result.stderr + + def test_index_html_clears_finished_active_task_after_normal_chat_turn() -> None: debugger = load_debugger_module() @@ -1190,6 +1342,112 @@ def test_index_html_can_restore_debugger_log_replay_payload(tmp_path: Path) -> N ] +def test_load_log_dir_replays_state_fetch_cancel_handoff_events(tmp_path: Path) -> None: + debugger = load_debugger_module() + log_dir = tmp_path / "logs" + log_dir.mkdir() + (log_dir / "sse-events.jsonl").write_text( + json.dumps( + { + "raw": { + "statusUpdate": { + "taskId": "task-pipeline", + "contextId": "ctx-1", + "status": {"state": "TASK_STATE_INPUT_REQUIRED"}, + "metadata": { + "iac_code": { + "pipeline": { + "eventType": "input_required", + "sequence": 72.0, + "taskId": "task-pipeline", + "contextId": "ctx-1", + "status": "input_required", + } + } + }, + } + } + } + ) + + "\n", + encoding="utf-8", + ) + (log_dir / "snapshots.jsonl").write_text( + json.dumps( + { + "raw": { + "snapshot": { + "status": "canceled", + "taskId": "task-pipeline", + "contextId": "ctx-1", + "lastSequence": 74, + "normalHandoff": { + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "canceled", + }, + }, + "events": [ + { + "eventType": "pipeline_canceled", + "sequence": 73, + "taskId": "task-pipeline", + "contextId": "ctx-1", + "status": "canceled", + }, + { + "eventType": "pipeline_handoff_ready", + "sequence": 74, + "taskId": "task-pipeline", + "contextId": "ctx-1", + "status": "canceled", + "data": { + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "canceled", + }, + }, + ], + } + } + ) + + "\n", + encoding="utf-8", + ) + + replay = debugger.load_debug_log_export(log_dir) + + assert replay["task"]["taskId"] == "task-pipeline" + assert replay["task"]["activeTaskId"] == "" + assert replay["task"]["contextId"] == "ctx-1" + assert replay["task"]["status"] == "canceled" + assert replay["task"]["lastSequence"] == 74 + assert [event["eventType"] for event in replay["sseEvents"][-2:]] == [ + "pipeline_canceled", + "pipeline_handoff_ready", + ] + + +def test_index_html_normal_handoff_summary_reads_snapshot_response_wrapper() -> None: + debugger = load_debugger_module() + + html = debugger.render_index_html( + debugger.DebuggerConfig( + host="127.0.0.1", + port=41880, + default_server_url="http://127.0.0.1:41299", + default_cwd="/workspace/demo", + ) + ) + normal_handoff_body = html.split("function snapshotNormalHandoff(snapshot)", 1)[1].split( + "function normalHandoffSummary(snapshot)", + 1, + )[0] + + assert "snapshotEnvelope(snapshot)" in normal_handoff_body + assert "snapshotObject(envelope &&" in normal_handoff_body + + def test_index_html_fills_context_and_task_id_controls_after_capture() -> None: debugger = load_debugger_module() @@ -1254,6 +1512,26 @@ def test_index_html_reads_input_required_data_and_clears_stale_permissions() -> assert expected in html +def test_index_html_highlights_pipeline_canceled_events() -> None: + debugger = load_debugger_module() + + html = debugger.render_index_html( + debugger.DebuggerConfig( + host="127.0.0.1", + port=41880, + default_server_url="http://127.0.0.1:41299", + default_cwd="/workspace/demo", + ) + ) + + for expected in [ + 'type === "pipeline_canceled"', + 'label: "pipeline canceled"', + "timeline-canceled", + ]: + assert expected in html + + def test_index_html_stops_stream_after_input_required_to_reenable_prompt_submit() -> None: debugger = load_debugger_module() @@ -1707,6 +1985,73 @@ def do_POST(self) -> None: self.end_headers() +class JsonRpcErrorTargetHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + return None + + def do_POST(self) -> None: + raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0") or "0")) + assert raw_body + body = json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "error": { + "code": -32602, + "message": "Current model text-only-model does not support image input.", + "data": { + "recoverableTaskId": "task-owner", + "contextId": "ctx-1", + "sidecarStatus": "running", + }, + }, + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def test_jsonrpc_error_message_includes_recoverable_task_id() -> None: + debugger = load_debugger_module() + + message = debugger._jsonrpc_error_message(RECOVERABLE_JSONRPC_ERROR) + + assert message is not None + assert "Pipeline already running." in message + assert "task-owner" in message + + +def test_index_html_extracts_delivery_task_aliases() -> None: + script = SCRIPT_PATH.read_text(encoding="utf-8") + + assert "statusUpdate.deliveryTaskId" in script + assert "statusUpdate.deliveryContextId" in script + assert "task.deliveryTaskId" in script + assert "envelope.deliveryTaskId" in script + + +def test_jsonrpc_error_message_does_not_duplicate_resume_guidance() -> None: + debugger = load_debugger_module() + value = { + "error": { + "code": -32602, + "message": "Pipeline already running. Resume task task-owner.", + "data": { + "recoverableTaskId": "task-owner", + "contextId": "ctx-1", + "sidecarStatus": "running", + }, + } + } + + message = debugger._jsonrpc_error_message(value) + + assert message == "Pipeline already running. Resume task task-owner." + + def test_message_stream_route_forwards_sse_and_uses_stream_payload() -> None: debugger = load_debugger_module() SseTargetHandler.requests = [] @@ -1738,6 +2083,81 @@ def test_message_stream_route_forwards_sse_and_uses_stream_payload() -> None: assert sent["params"]["message"]["metadata"] == {"iac_code": {"cwd": "/workspace/demo"}} +def test_message_stream_route_forwards_image_parts() -> None: + debugger = load_debugger_module() + SseTargetHandler.requests = [] + + with serve_handler(SseTargetHandler) as target_url: + running = start_debugger_server(debugger) + try: + status, body = post_raw( + f"{running.url}/api/message/stream", + { + "serverUrl": target_url, + "cwd": "/workspace/demo", + "contextId": "ctx-1", + "prompt": "inspect this diagram", + "images": [ + { + "filename": "diagram.png", + "mediaType": "image/png", + "bytes": "iVBORw0KGgo=", + } + ], + }, + ) + finally: + running.close() + + assert status == 200 + assert "data: " in body + sent = json.loads(SseTargetHandler.requests[0]["body"]) + assert sent["params"]["message"]["parts"] == [ + {"text": "inspect this diagram"}, + { + "data": {"filename": "diagram.png", "bytes": "iVBORw0KGgo="}, + "mediaType": "image/png", + }, + ] + + +def test_message_stream_route_allows_image_only_prompt() -> None: + debugger = load_debugger_module() + SseTargetHandler.requests = [] + + with serve_handler(SseTargetHandler) as target_url: + running = start_debugger_server(debugger) + try: + status, body = post_raw( + f"{running.url}/api/message/stream", + { + "serverUrl": target_url, + "cwd": "/workspace/demo", + "contextId": "ctx-1", + "prompt": "", + "images": [ + { + "filename": "diagram.png", + "mediaType": "image/png", + "bytes": "iVBORw0KGgo=", + } + ], + }, + ) + finally: + running.close() + + assert status == 200 + assert "data: " in body + sent = json.loads(SseTargetHandler.requests[0]["body"]) + assert sent["params"]["message"]["parts"] == [ + { + "data": {"filename": "diagram.png", "bytes": "iVBORw0KGgo="}, + "mediaType": "image/png", + }, + ] + + def test_message_stream_route_writes_sse_debugger_log(tmp_path: Path) -> None: debugger = load_debugger_module() SseTargetHandler.requests = [] @@ -1795,6 +2215,35 @@ def test_message_stream_route_logs_empty_upstream_stream(tmp_path: Path) -> None assert records[-1]["raw"] == {"type": "stream_empty", "statusCode": 200} +def test_message_stream_route_converts_jsonrpc_error_to_sse_error(tmp_path: Path) -> None: + debugger = load_debugger_module() + + with serve_handler(JsonRpcErrorTargetHandler) as target_url: + running = start_logged_debugger_server(debugger, log_dir=tmp_path) + try: + status, body = post_raw( + f"{running.url}/api/message/stream", + { + "serverUrl": target_url, + "cwd": "/workspace/demo", + "contextId": "ctx-1", + "prompt": "inspect image", + }, + ) + finally: + running.close() + + assert status == 200 + assert "data: " in body + assert "Current model text-only-model does not support image input." in body + assert "task-owner" in body + records = read_jsonl(tmp_path / "sse-events.jsonl") + assert records[-1]["parsedEventType"] == "error" + assert records[-1]["raw"]["type"] == "error" + assert records[-1]["raw"]["body"]["error"]["code"] == -32602 + assert records[-1]["raw"]["body"]["error"]["data"]["recoverableTaskId"] == "task-owner" + + def test_message_stream_route_ignores_client_disconnect_without_traceback(capsys: pytest.CaptureFixture[str]) -> None: debugger = load_debugger_module() diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py index 81426691..306b64ef 100644 --- a/tests/a2a/test_pipeline_events.py +++ b/tests/a2a/test_pipeline_events.py @@ -58,6 +58,61 @@ def test_pipeline_started_has_stable_envelope() -> None: assert envelope["data"]["totalSteps"] == 4 +def test_pipeline_warning_translates_to_non_terminal_envelope() -> None: + translator = PipelineEventTranslator(_ctx()) + + [envelope] = translator.translate( + PipelineEvent( + type=PipelineEventType.PIPELINE_WARNING, + step_id="deploying", + timestamp=1717821600.0, + data={ + "reason": "cleanup_tracking_unavailable", + "operation": "record_observed", + "ledger_path": "/Users/alice/.iac-code/projects/demo/cleanup.yaml", + "load_error": "while parsing /Users/alice/.iac-code/projects/demo/cleanup.yaml", + }, + ) + ) + + assert envelope["eventType"] == "pipeline_warning" + assert envelope["scope"] == "pipeline" + assert envelope["status"] == "working" + assert envelope["data"]["reason"] == "cleanup_tracking_unavailable" + assert "ledger_path" not in envelope["data"] + assert "load_error" not in envelope["data"] + + +def test_manual_cleanup_event_normalizes_cleanup_data_keys() -> None: + translator = PipelineEventTranslator(_ctx()) + + event = translator.manual_event( + "cleanup_started", + "cleanup", + data={ + "resource_count": 1, + "status_message": "检测到 1 个回滚残留资源,开始清理流程。", + "resource_id": "stack-123", + "region_id": "cn-hangzhou", + "stack_status": "DELETE_IN_PROGRESS", + "cleanup_tool_use_id": "toolu-get", + "progress_percentage": 60, + "last_error": "DELETE_FAILED", + }, + ) + + assert event["eventType"] == "cleanup_started" + assert event["scope"] == "cleanup" + assert event["data"]["resourceCount"] == 1 + assert event["data"]["statusMessage"] == "检测到 1 个回滚残留资源,开始清理流程。" + assert event["data"]["resourceId"] == "stack-123" + assert event["data"]["regionId"] == "cn-hangzhou" + assert event["data"]["stackStatus"] == "DELETE_IN_PROGRESS" + assert event["data"]["cleanupToolUseId"] == "toolu-get" + assert event["data"]["progressPercentage"] == 60 + assert event["data"]["lastError"] == "DELETE_FAILED" + + def test_parent_step_attempt_increments_after_rollback() -> None: translator = PipelineEventTranslator(_ctx()) translator.translate( @@ -768,6 +823,48 @@ def test_show_candidate_detail_tool_result_recovers_detail_from_tool_input() -> assert detail_event["data"]["detail"]["costItems"] == [{"name": "ecs", "monthly_cost": "CNY 60"}] +@pytest.mark.parametrize( + ("stream_event", "event_type"), + [ + (TextDeltaEvent(text="开始部署资源"), "text_delta"), + ( + ToolResultEvent( + tool_use_id="toolu-read", + tool_name="read_file", + result="template content", + is_error=False, + ), + "tool_result", + ), + ( + PermissionRequestEvent( + tool_name="ros_stack", + tool_input={"action": "CreateStack"}, + tool_use_id="toolu-stack", + ), + "permission_requested", + ), + ], +) +def test_parent_stream_events_include_current_step_coordinate(stream_event: object, event_type: str) -> None: + translator = PipelineEventTranslator(_ctx()) + translator.translate( + PipelineEvent( + type=PipelineEventType.STEP_STARTED, + step_id="deploying", + timestamp=time.time(), + data={"index": 5, "total": 5}, + ) + ) + + [envelope] = translator.translate(stream_event) + + assert envelope["eventType"] == event_type + assert envelope["scope"] == "step" + assert envelope["step"]["id"] == "deploying" + assert envelope["step"]["runId"] == "step-deploying-1" + + def test_stack_current_changed_is_disabled_by_default() -> None: translator = PipelineEventTranslator(_ctx()) translator.translate( @@ -964,7 +1061,7 @@ def test_stack_current_changed_emits_after_successful_ros_create_stack() -> None } -def test_stack_current_changed_clears_current_stack_after_successful_delete() -> None: +def test_stack_current_changed_keeps_current_stack_after_statusless_successful_delete() -> None: ctx = _ctx() ctx.emit_stack_events = True translator = PipelineEventTranslator(ctx) @@ -993,6 +1090,48 @@ def test_stack_current_changed_clears_current_stack_after_successful_delete() -> assert stack_event["eventType"] == "stack_current_changed" assert stack_event["data"]["action"] == "DeleteStack" assert stack_event["data"]["stackId"] == "stack-123" + assert stack_event["data"]["stackStatus"] == "DELETE_REQUESTED" + assert stack_event["data"]["current"] is True + assert "cleared" not in stack_event["data"] + + +def test_stack_current_changed_clears_current_stack_after_delete_complete() -> None: + ctx = _ctx() + ctx.emit_stack_events = True + translator = PipelineEventTranslator(ctx) + translator.translate( + ToolUseEndEvent( + tool_use_id="toolu-delete", + name="ros_stack", + input={ + "action": "DeleteStack", + "region_id": "cn-hangzhou", + "params": {"StackId": "stack-123", "StackName": "demo"}, + }, + ) + ) + + envelopes = translator.translate( + ToolResultEvent( + tool_use_id="toolu-delete", + tool_name="ros_stack", + result=json.dumps( + { + "stack_id": "stack-123", + "stack_name": "demo", + "status": "DELETE_COMPLETE", + "is_success": True, + } + ), + is_error=False, + ) + ) + + stack_event = envelopes[0] + assert stack_event["eventType"] == "stack_current_changed" + assert stack_event["data"]["action"] == "DeleteStack" + assert stack_event["data"]["stackId"] == "stack-123" + assert stack_event["data"]["stackStatus"] == "DELETE_COMPLETE" assert stack_event["data"]["current"] is False assert stack_event["data"]["cleared"] is True diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index 7b69d52a..a7f90725 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -14,10 +14,15 @@ from iac_code.a2a.executor import IacCodeA2AExecutor from iac_code.a2a.metrics import NoOpA2AMetrics +from iac_code.a2a.persistence import A2APersistenceStore from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events from iac_code.a2a.task_store import A2ATaskStore +from iac_code.agent.message import ImageBlock +from iac_code.pipeline.engine.cleanup import CleanupLedger, CleanupResource from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.pipeline.engine.interrupt import InterruptVerdict +from iac_code.pipeline.engine.user_input import PipelineUserInput from iac_code.types.stream_events import AskUserQuestionEvent, TextDeltaEvent from .fakes import FakeEventQueue, FakeRequestContext @@ -26,10 +31,63 @@ AUTH_TEXT = "Authentication required. Configure credentials and retry." +def test_active_sidecar_mismatch_error_exposes_jsonrpc_data() -> None: + from iac_code.a2a.pipeline_executor import _active_sidecar_mismatch_error + + error = _active_sidecar_mismatch_error( + recoverable_task_id="task-owner", + context_id="ctx-1", + sidecar_status="running", + ) + + assert error.code == -32602 + assert error.data == { + "recoverableTaskId": "task-owner", + "contextId": "ctx-1", + "sidecarStatus": "running", + } + assert "task-owner" in error.message + + +def test_active_sidecar_mismatch_error_serializes_raw_jsonrpc_data() -> None: + from iac_code.a2a.jsonrpc_passthrough import install_jsonrpc_error_data_passthrough + from iac_code.a2a.pipeline_executor import _active_sidecar_mismatch_error + + install_jsonrpc_error_data_passthrough() + from a2a.server.request_handlers.response_helpers import build_error_response + + error = _active_sidecar_mismatch_error( + recoverable_task_id="task-owner", + context_id="ctx-1", + sidecar_status="waiting_input", + ) + + response = build_error_response("req-1", error) + + assert response["error"]["code"] == -32602 + assert response["error"]["data"] == { + "recoverableTaskId": "task-owner", + "contextId": "ctx-1", + "sidecarStatus": "waiting_input", + } + + def dump(event): return MessageToDict(event, preserving_proto_field_name=False) +def image_interrupt_input() -> PipelineUserInput: + return PipelineUserInput( + content=[ImageBlock(media_type="image/png", data="aGVsbG8=")], + display_text="[Image input]", + has_images=True, + ) + + +def _display_text(value): + return value.display_text if isinstance(value, PipelineUserInput) else value + + class FakePipeline: def __init__(self, events, *, session_dir: Path) -> None: self.events = events @@ -46,14 +104,14 @@ def __init__(self, events, *, session_dir: Path) -> None: self.handoff_summary = "handoff summary" async def run(self, prompt: str): - self.run_prompts.append(prompt) + self.run_prompts.append(_display_text(prompt)) for event in self.events: if isinstance(event, BaseException): raise event yield event async def resume(self, prompt: str): - self.resume_prompts.append(prompt) + self.resume_prompts.append(_display_text(prompt)) for event in self.events: if isinstance(event, BaseException): raise event @@ -61,7 +119,7 @@ async def resume(self, prompt: str): def continue_from_sidecar(self, user_input: str | None = None): self.continue_calls += 1 - self.continue_inputs.append(user_input) + self.continue_inputs.append(_display_text(user_input)) return self.run(user_input or "continued") def clear_sidecar(self) -> None: @@ -254,6 +312,96 @@ def fake_create_pipeline(*args, **kwargs): assert messages[-1].content == "[Pipeline Handoff Context]\nPipeline: selling" +@pytest.mark.asyncio +async def test_executor_publishes_normal_handoff_ready_with_cleanup_resources( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + ledger = CleanupLedger(session_dir / "cleanup.yaml") + ledger.mark_cleanup_required( + [ + CleanupResource( + provider="ros", + resource_type="stack", + resource_id="stack-123", + resource_name="selling-stack", + region_id="cn-hangzhou", + source_step_id="deploying", + ) + ], + source_step_id="deploying", + reason="rollback from deploying", + ) + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), + ], + session_dir=session_dir, + ) + fake_pipeline.handoff_enabled = True + fake_pipeline.handoff_summary = "[Pipeline Handoff Context]\nPipeline: selling" + fake_pipeline.cleanup_ledger = lambda: ledger + + def fake_create_pipeline(*args, **kwargs): + fake_pipeline._session_storage = kwargs["session_storage"] + fake_pipeline._session_id = kwargs["session_id"] + fake_pipeline._cwd = kwargs["cwd"] + return fake_pipeline + + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + pipeline_events = [ + dump(event)["metadata"]["iac_code"]["pipeline"] + for event in queue.events + if isinstance(event, TaskStatusUpdateEvent) + and "pipeline" in dump(event).get("metadata", {}).get("iac_code", {}) + ] + handoff = pipeline_events[-1] + cleanup = handoff["data"]["cleanup"] + assert cleanup["status"] == "pending" + assert cleanup["resourceCount"] == 1 + assert cleanup["statusMessage"] == "Detected 1 rollback cleanup resources; starting cleanup." + assert "prompt" not in cleanup + assert "ledgerPath" not in cleanup + assert cleanup["resources"] == [ + { + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-123", + "resourceName": "selling-stack", + "regionId": "cn-hangzhou", + "sourceStepId": "deploying", + "cleanupStatus": "pending", + "progressStatus": None, + "lastError": None, + } + ] + + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["cleanup"]["status"] == "pending" + assert snapshot["cleanup"]["resourceCount"] == 1 + assert snapshot["normalHandoff"]["data"]["cleanup"]["resourceCount"] == 1 + assert "prompt" not in snapshot["cleanup"] + assert "ledgerPath" not in snapshot["cleanup"] + assert "prompt" not in snapshot["normalHandoff"]["data"]["cleanup"] + assert "ledgerPath" not in snapshot["normalHandoff"]["data"]["cleanup"] + + @pytest.mark.asyncio async def test_executor_sets_pipeline_telemetry_correlation( monkeypatch: pytest.MonkeyPatch, @@ -272,8 +420,10 @@ async def test_executor_sets_pipeline_telemetry_correlation( session_dir=tmp_path / "sidecar", ) fake_pipeline.set_telemetry_correlation = MagicMock() + create_pipeline_kwargs = {} def fake_create_pipeline(*args, **kwargs): + create_pipeline_kwargs.update(kwargs) fake_pipeline._session_storage = kwargs["session_storage"] fake_pipeline._session_id = kwargs["session_id"] fake_pipeline._cwd = kwargs["cwd"] @@ -295,6 +445,7 @@ def fake_create_pipeline(*args, **kwargs): context_id="ctx-1", pipeline_run_id="ctx-1", ) + assert create_pipeline_kwargs["surface"] == "a2a" @pytest.mark.asyncio @@ -775,12 +926,10 @@ async def test_executor_clears_previous_task_terminal_sidecar_and_runs_new_task( @pytest.mark.parametrize( ("sidecar_status", "event_type", "event_status"), [ - ("waiting_input", "input_required", "waiting_input"), - ("running", "pipeline_started", "working"), ("completed", "pipeline_completed", "completed"), ], ) -async def test_executor_replaces_restored_pipeline_when_sidecar_owner_mismatches( +async def test_executor_replaces_terminal_restored_pipeline_when_sidecar_owner_mismatches( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, sidecar_status: str, @@ -857,6 +1006,102 @@ def fake_create_pipeline(*args, **kwargs): assert "".join(record.output_text) == "fresh output" +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("sidecar_status", "event_type", "event_status"), + [ + ("waiting_input", "input_required", "waiting_input"), + ("running", "pipeline_started", "working"), + ], +) +async def test_executor_rejects_active_restored_pipeline_owner_mismatch_without_clearing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + sidecar_status: str, + event_type: str, + event_status: str, +) -> None: + from iac_code.a2a.pipeline_executor import ( + IacCodeA2APipelineExecutor, + RecoverablePipelineInvalidParamsError, + ) + + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + owner_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-owner", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": event_type, + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-owner", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": event_status, + "data": {"prompt": "owner choice"} if event_type == "input_required" else {}, + } + journal = A2APipelineJournal(session_dir) + journal.append(owner_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([owner_event])) + restored_pipeline = FakePipeline( + [ + TextDeltaEvent(text="stale restored output"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, + ) + restored_pipeline.sidecar_status = sidecar_status + created_pipelines: list[FakePipeline] = [] + + def fake_create_pipeline(*args, **kwargs): + created_pipelines.append(restored_pipeline) + return restored_pipeline + + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: + await executor.execute( + context=FakeRequestContext( + task_id="task-new", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + event_queue=FakeEventQueue(), + task=await store.get_or_create_task(task_id="task-new", context_id="ctx-1"), + task_id="task-new", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="new request", + ) + + assert exc_info.value.data == { + "recoverableTaskId": "task-owner", + "contextId": "ctx-1", + "sidecarStatus": sidecar_status, + } + assert len(created_pipelines) == 1 + assert restored_pipeline.clear_sidecar_calls == 0 + assert restored_pipeline.run_prompts == [] + assert journal.read_all() == [owner_event] + + @pytest.mark.asyncio async def test_executor_keeps_a2a_metadata_when_mismatch_clears_pipeline_sidecar( monkeypatch: pytest.MonkeyPatch, @@ -1602,10 +1847,12 @@ async def test_executor_does_not_resume_nonterminal_sidecar_when_a2a_state_is_te @pytest.mark.asyncio -async def test_executor_clears_previous_task_waiting_sidecar_and_runs_new_task( +async def test_executor_rejects_previous_task_waiting_sidecar_without_starting_new_task( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: + from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" old_input = { @@ -1638,19 +1885,25 @@ async def test_executor_clears_previous_task_waiting_sidecar_and_runs_new_task( executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") - await executor.execute( - FakeRequestContext( - task_id="task-new", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), - ) + with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: + await executor.execute( + FakeRequestContext( + task_id="task-new", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) - assert fake_pipeline.clear_sidecar_calls == 1 + assert exc_info.value.data == { + "recoverableTaskId": "task-old", + "contextId": "ctx-1", + "sidecarStatus": "waiting_input", + } + assert fake_pipeline.clear_sidecar_calls == 0 assert fake_pipeline.resume_prompts == [] - assert fake_pipeline.run_prompts == ["new request"] + assert fake_pipeline.run_prompts == [] @pytest.mark.asyncio @@ -1668,6 +1921,8 @@ async def test_executor_does_not_attach_current_sidecar_to_historical_task( event_type: str, event_status: str, ) -> None: + from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" old_event = { @@ -1712,21 +1967,27 @@ async def test_executor_does_not_attach_current_sidecar_to_historical_task( executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") - await executor.execute( - FakeRequestContext( - task_id="task-old", - context_id="ctx-1", - text="old followup", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), - ) + with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: + await executor.execute( + FakeRequestContext( + task_id="task-old", + context_id="ctx-1", + text="old followup", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) - assert fake_pipeline.clear_sidecar_calls == 1 + assert exc_info.value.data == { + "recoverableTaskId": "task-current", + "contextId": "ctx-1", + "sidecarStatus": sidecar_status, + } + assert fake_pipeline.clear_sidecar_calls == 0 assert fake_pipeline.resume_prompts == [] assert fake_pipeline.continue_calls == 0 - assert fake_pipeline.run_prompts == ["old followup"] - assert journal.read_all()[-1]["taskId"] == "task-old" + assert fake_pipeline.run_prompts == [] + assert journal.read_all()[-1]["taskId"] == "task-current" @pytest.mark.asyncio @@ -1891,10 +2152,12 @@ async def test_executor_routes_waiting_input_pause_confirmation_through_interrup @pytest.mark.asyncio -async def test_executor_clears_previous_task_running_sidecar_and_runs_new_task( +async def test_executor_rejects_previous_task_running_sidecar_without_starting_new_task( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: + from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" old_running = { @@ -1927,37 +2190,144 @@ async def test_executor_clears_previous_task_running_sidecar_and_runs_new_task( executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") - await executor.execute( - FakeRequestContext( - task_id="task-new", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), - ) + with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: + await executor.execute( + FakeRequestContext( + task_id="task-new", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) - assert fake_pipeline.clear_sidecar_calls == 1 + assert exc_info.value.data == { + "recoverableTaskId": "task-old", + "contextId": "ctx-1", + "sidecarStatus": "running", + } + assert fake_pipeline.clear_sidecar_calls == 0 assert fake_pipeline.continue_calls == 0 - assert fake_pipeline.run_prompts == ["new request"] + assert fake_pipeline.run_prompts == [] @pytest.mark.asyncio -async def test_pipeline_executor_routes_second_prompt_as_interrupt(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor - from iac_code.a2a.pipeline_journal import A2APipelineJournal - from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher +async def test_executor_rejected_active_sidecar_mismatch_does_not_persist_new_working_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError - class InterruptiblePipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([TextDeltaEvent(text="running")], session_dir=session_dir) - self.interrupts: list[str] = [] + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + persistence = A2APersistenceStore(tmp_path / "a2a") + session_dir = tmp_path / "sidecar" + owner_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-owner-running", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_started", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-owner", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "working", + "data": {}, + } + A2APipelineJournal(session_dir).append(owner_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([owner_event])) + fake_pipeline = FakePipeline( + [ + TextDeltaEvent(text="new output"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = "running" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - self.interrupts.append(message) - return SimpleNamespace( + task_store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + executor = IacCodeA2AExecutor(task_store=task_store, model="qwen3.6-plus") + + with pytest.raises(RecoverablePipelineInvalidParamsError): + await executor.execute( + FakeRequestContext( + task_id="task-new", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) + + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.run_prompts == [] + rejected_task = persistence.load_task("task-new") + assert rejected_task is not None + assert rejected_task.state != "working" + assert [task.task_id for task in persistence.list_tasks() if task.state == "working"] == [] + + +def test_cleanup_handoff_missing_ledger_ignores_empty_public_cleanup_snapshot(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import _pipeline_cleanup_handoff_data_from_session + + cleanup = _pipeline_cleanup_handoff_data_from_session( + cwd=str(tmp_path), + session_id="session-empty-cleanup", + public_snapshot={"cleanup": {"resourceCount": 0, "resources": [], "status": ""}}, + ) + + assert cleanup is None + + +def test_cleanup_handoff_missing_ledger_does_not_reconstruct_prompt_from_public_snapshot(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import _pipeline_cleanup_handoff_data_from_session + + cleanup = _pipeline_cleanup_handoff_data_from_session( + cwd=str(tmp_path), + session_id="session-public-cleanup-only", + public_snapshot={ + "cleanup": { + "resourceCount": 1, + "resources": [ + { + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-public-only", + "cleanupStatus": "pending", + } + ], + "status": "pending", + } + }, + ) + + assert cleanup is not None + assert cleanup["status"] == "unavailable" + assert "prompt" not in cleanup + assert "resources" not in cleanup + assert "stack-public-only" not in repr(cleanup) + + +@pytest.mark.asyncio +async def test_pipeline_executor_routes_second_prompt_as_interrupt(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + class InterruptiblePipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([TextDeltaEvent(text="running")], session_dir=session_dir) + self.interrupts: list[str] = [] + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + self.interrupts.append(message) + return SimpleNamespace( action="supplement", reason="added context", rollback_target=None, @@ -2942,7 +3312,7 @@ async def handle_user_interrupt(self, message: str) -> SimpleNamespace: task_id="task-1", context_id="ctx-1", cwd=str(tmp_path), - prompt="Nginx 网站", + pipeline_input="Nginx 网站", preserve_task_record=True, ) @@ -3035,7 +3405,7 @@ async def handle_user_interrupt(self, message: str) -> SimpleNamespace: task_id="task-1", context_id="ctx-1", cwd=str(tmp_path), - prompt="Nginx 网站", + pipeline_input="Nginx 网站", preserve_task_record=True, ) @@ -3351,6 +3721,120 @@ def test_waiting_input_task_id_from_sidecar_accepts_candidate_selection(tmp_path assert waiting_input_task_id_from_sidecar(cwd=str(cwd), session_id=session_id, context_id=context_id) == "task-1" +def test_cancel_waiting_input_sidecar_appends_cancel_handoff_as_durable_group( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.a2a.pipeline_executor import cancel_waiting_input_task_from_sidecar + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-selection", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": context_id, + "taskId": "task-1", + "contextId": context_id, + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, + "input": { + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], + }, + } + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + append_many_calls = [] + original_append_many = A2APipelineJournal.append_many + + def recording_append_many(self, events, durable: bool = False): + append_many_calls.append(([event["eventType"] for event in events], durable)) + return original_append_many(self, events, durable=durable) + + monkeypatch.setattr(A2APipelineJournal, "append_many", recording_append_many) + + canceled = cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + reason="user canceled", + ) + + assert canceled is True + assert append_many_calls[-1] == (["pipeline_canceled", "pipeline_handoff_ready"], True) + events = A2APipelineJournal(pipeline_dir).read_all() + assert [event["eventType"] for event in events[-2:]] == ["pipeline_canceled", "pipeline_handoff_ready"] + + +def test_cancel_waiting_input_sidecar_returns_false_when_durable_group_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.a2a.pipeline_executor import cancel_waiting_input_task_from_sidecar + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-selection", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": context_id, + "taskId": "task-1", + "contextId": context_id, + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, + "input": { + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], + }, + } + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + + def fail_append_many(self, events, durable: bool = False): + assert durable is True + assert [event["eventType"] for event in events] == ["pipeline_canceled", "pipeline_handoff_ready"] + raise OSError("journal locked") + + monkeypatch.setattr(A2APipelineJournal, "append_many", fail_append_many) + + canceled = cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + reason="user canceled", + ) + + assert canceled is False + assert [event["eventType"] for event in A2APipelineJournal(pipeline_dir).read_all()] == ["input_required"] + snapshot = A2APipelineSnapshotStore(pipeline_dir).load() + assert snapshot is not None + assert snapshot["status"] == "waiting_input" + + @pytest.mark.asyncio async def test_executor_recovers_pending_ask_from_journal_when_snapshot_is_missing( monkeypatch: pytest.MonkeyPatch, @@ -4283,3 +4767,363 @@ async def test_same_task_non_interruptible_active_context_preserves_active_recor final_status = _status_events(queue)[-1]["status"] assert final_status["state"] == "TASK_STATE_FAILED" assert final_status["message"]["parts"][0]["text"] == "Task is already working." + + +@pytest.mark.asyncio +async def test_active_pipeline_interrupt_receives_structured_image_input(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task.active_task = asyncio.current_task() + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda session_id: _fake_runtime(), + ) + ctx.active_task_id = "task-1" + received = [] + + class InterruptPipeline(FakePipeline): + async def handle_user_interrupt(self, message): + received.append(message) + return InterruptVerdict(action="continue", reason="keep going") + + def pause_agent_loops(self) -> None: + pass + + def resume_agent_loops(self) -> None: + pass + + pipeline = InterruptPipeline([], session_dir=tmp_path / "pipeline") + publisher = SimpleNamespace( + publish_interrupt_received=AsyncMock(), + publish_interrupt=AsyncMock(), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + ctx.runtime = SimpleNamespace( + agent_runtime=_fake_runtime(), + pipeline=pipeline, + publisher=publisher, + current_stream=None, + restart_after_interrupt=False, + pause_after_interrupt=False, + restart_requested=asyncio.Event(), + ) + store.mirror_context(ctx) + pipeline_input = image_interrupt_input() + + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + await executor.execute( + context=FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), + event_queue=FakeEventQueue(), + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + pipeline_input=pipeline_input, + ) + + assert received == [pipeline_input] + publisher.publish_interrupt_received.assert_awaited_once_with(prompt="[Image input]") + + +@pytest.mark.asyncio +async def test_active_pending_question_answer_preserves_image_input(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, _PendingAskUserQuestion + + future = asyncio.get_running_loop().create_future() + injected = [] + + class Pipeline: + def inject_pending_question_supplement(self, message, *, envelope): + injected.append((message, envelope)) + + runtime = SimpleNamespace( + pending_question=_PendingAskUserQuestion( + event=AskUserQuestionEvent( + tool_use_id="toolu_1", + question="Upload diagram", + options=[], + response_future=future, + ), + envelope={"scope": "pipeline", "inputId": "ask-toolu_1"}, + ), + pipeline=Pipeline(), + publisher=SimpleNamespace( + publish_manual=AsyncMock(return_value=object()), + ), + ) + pipeline_input = image_interrupt_input() + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + result = await executor._route_pending_question_answer(runtime, pipeline_input) + + assert result == "answered" + answer = future.result() + assert answer == {"selected_id": "", "selected_label": "", "free_text": "[Image input]"} + assert injected == [(pipeline_input.content, {"scope": "pipeline", "inputId": "ask-toolu_1"})] + + +@pytest.mark.asyncio +async def test_active_pending_question_image_injection_failure_is_not_marked_answered(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, _PendingAskUserQuestion + + future = asyncio.get_running_loop().create_future() + + class Pipeline: + def inject_pending_question_supplement(self, message, *, envelope): + return False + + runtime = SimpleNamespace( + pending_question=_PendingAskUserQuestion( + event=AskUserQuestionEvent( + tool_use_id="toolu_1", + question="Upload diagram", + options=[], + response_future=future, + ), + envelope={"scope": "pipeline", "inputId": "ask-toolu_1"}, + ), + pipeline=Pipeline(), + publisher=SimpleNamespace( + publish_manual=AsyncMock(return_value=object()), + ), + ) + pipeline_input = image_interrupt_input() + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + with pytest.raises(RuntimeError, match="image supplement could not be delivered"): + await executor._route_pending_question_answer(runtime, pipeline_input) + + assert future.done() is False + assert runtime.pending_question is not None + + +@pytest.mark.asyncio +async def test_active_pending_question_image_injection_failure_restores_snapshot_pending_input(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, _PendingAskUserQuestion + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + future = asyncio.get_running_loop().create_future() + + class Pipeline: + def inject_pending_question_supplement(self, message, *, envelope): + return False + + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + await publisher.publish_manual( + "input_required", + "pipeline", + status="input_required", + data={ + "kind": "ask_user_question", + "inputId": "ask-toolu_1", + "toolUseId": "toolu_1", + "question": "Upload diagram", + "prompt": "Upload diagram", + "options": [], + "required": True, + }, + ) + assert publisher.snapshot_store.load()["pendingInput"]["inputId"] == "ask-toolu_1" + + runtime = SimpleNamespace( + pending_question=_PendingAskUserQuestion( + event=AskUserQuestionEvent( + tool_use_id="toolu_1", + question="Upload diagram", + options=[], + response_future=future, + ), + envelope={"scope": "pipeline", "inputId": "ask-toolu_1"}, + ), + pipeline=Pipeline(), + publisher=publisher, + ) + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + with pytest.raises(RuntimeError, match="image supplement could not be delivered"): + await executor._route_pending_question_answer(runtime, image_interrupt_input()) + + snapshot = publisher.snapshot_store.load() + assert snapshot["status"] == "waiting_input" + assert snapshot["pendingInput"]["inputId"] == "ask-toolu_1" + assert future.done() is False + assert runtime.pending_question is not None + + +@pytest.mark.asyncio +async def test_execute_reports_active_pending_question_image_injection_failure(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import ( + A2APipelineRuntime, + IacCodeA2APipelineExecutor, + _PendingAskUserQuestion, + ) + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + future = asyncio.get_running_loop().create_future() + + class Pipeline: + def inject_pending_question_supplement(self, message, *, envelope): + return False + + queue = FakeEventQueue() + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + publisher.publish_manual = AsyncMock(return_value=object()) # type: ignore[method-assign] + runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=Pipeline(), publisher=publisher) + runtime.pending_question = _PendingAskUserQuestion( + event=AskUserQuestionEvent( + tool_use_id="toolu_1", + question="Upload diagram", + options=[], + response_future=future, + ), + envelope={"scope": "pipeline", "inputId": "ask-toolu_1"}, + ) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task.state = "input-required" + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + ctx.runtime = runtime + ctx.active_task_id = "task-1" + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + await executor.execute( + context=FakeRequestContext(task_id="task-1", context_id="ctx-1"), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + pipeline_input=image_interrupt_input(), + ) + + states = [dump(event)["status"]["state"] for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + assert "TASK_STATE_FAILED" in states + assert future.done() is False + assert runtime.pending_question is not None + + +@pytest.mark.asyncio +async def test_pending_ask_user_question_resume_preserves_image_input(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import _resume_pending_ask_user_question_stream + + pipeline_input = image_interrupt_input() + received = {} + + class AskPipeline(FakePipeline): + sidecar_status = "waiting_input" + + async def resume_ask_user_question(self, answer, **kwargs): + received["answer"] = answer + received["supplemental_input"] = kwargs.get("supplemental_input") + yield PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id="ask", + timestamp=0.0, + data={"total_steps": 1}, + ) + + pending_input = { + "kind": "ask_user_question", + "toolUseId": "toolu_1", + "inputId": "ask-toolu_1", + } + pipeline = AskPipeline([], session_dir=tmp_path / "pipeline") + publisher = SimpleNamespace( + snapshot_store=SimpleNamespace(load=lambda: {"status": "waiting_input"}), + publish_manual=AsyncMock(return_value=object()), + ) + + stream = _resume_pending_ask_user_question_stream( + pipeline=pipeline, + publisher=publisher, + pending_input=pending_input, + prompt="[Image input]", + pipeline_input=pipeline_input, + ) + events = [event async for event in stream] + + assert events + assert received["supplemental_input"] == pipeline_input diff --git a/tests/a2a/test_pipeline_journal.py b/tests/a2a/test_pipeline_journal.py index 16bf1986..19a997dd 100644 --- a/tests/a2a/test_pipeline_journal.py +++ b/tests/a2a/test_pipeline_journal.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from iac_code.a2a.pipeline_journal import A2APipelineJournal @@ -37,6 +39,42 @@ def test_read_after_filters_by_sequence(tmp_path) -> None: assert [event["eventId"] for event in journal.read_after(1)] == ["evt-2", "evt-3"] +def test_append_many_replays_group_as_events(tmp_path) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + + journal.append_many([_event(1, "evt-cancel"), _event(2, "evt-handoff")], durable=True) + + assert [event["eventId"] for event in journal.read_all_strict()] == ["evt-cancel", "evt-handoff"] + + +def test_append_many_sorts_group_events_with_regular_events(tmp_path) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + + journal.append(_event(3, "evt-after")) + journal.append_many([_event(1, "evt-cancel"), _event(2, "evt-handoff")], durable=True) + + assert [event["eventId"] for event in journal.read_all()] == ["evt-cancel", "evt-handoff", "evt-after"] + + +@pytest.mark.parametrize("write_method", ["append", "append_many"]) +def test_durable_append_fsyncs_parent_directory_when_journal_is_created( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + write_method: str, +) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + calls = [] + + monkeypatch.setattr("iac_code.a2a.pipeline_journal.fsync_parent_dir", calls.append, raising=False) + + if write_method == "append": + journal.append(_event(1, "evt-1"), durable=True) + else: + journal.append_many([_event(1, "evt-1"), _event(2, "evt-2")], durable=True) + + assert calls == [journal.path] + + def test_invalid_json_lines_are_skipped(tmp_path) -> None: journal = A2APipelineJournal(tmp_path / "pipeline") journal.append(_event(1, "evt-1")) diff --git a/tests/a2a/test_pipeline_recovery.py b/tests/a2a/test_pipeline_recovery.py index bd5f7a98..2250646a 100644 --- a/tests/a2a/test_pipeline_recovery.py +++ b/tests/a2a/test_pipeline_recovery.py @@ -60,6 +60,49 @@ async def test_recovery_returns_snapshot_and_replay_events(tmp_path) -> None: assert [event["eventId"] for event in state["events"]] == ["evt-2"] +@pytest.mark.asyncio +async def test_recovery_keeps_pipeline_warning_visible_after_snapshot_sequence(tmp_path) -> None: + persistence = A2APersistenceStore(tmp_path / "a2a") + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda session_id: object(), + ) + context = await store.get_context_record("ctx-1") + pipeline_dir = SessionStorage().session_dir(str(tmp_path), context.session_id) / "pipeline" + started = _event(1, "evt-1") + warning = _event(2, "evt-warning") + warning["eventType"] = "pipeline_warning" + warning["data"] = { + "reason": "cleanup_tracking_unavailable", + "operation": "record_observed", + "ledger_path": "/Users/alice/.iac-code/projects/demo/cleanup.yaml", + "load_error": "while parsing /Users/alice/.iac-code/projects/demo/cleanup.yaml", + } + journal = A2APipelineJournal(pipeline_dir) + journal.append(started) + journal.append(warning) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([started, warning])) + + service = A2APipelineRecoveryService(task_store=store) + state = await service.get_state(context_id="ctx-1") + + assert state["events"] == [] + assert state["snapshot"]["lastSequence"] == 2 + assert state["snapshot"]["control"]["warningHistory"][0]["eventId"] == "evt-warning" + assert state["snapshot"]["control"]["warningHistory"][0]["data"]["reason"] == "cleanup_tracking_unavailable" + assert "ledger_path" not in state["snapshot"]["control"]["warningHistory"][0]["data"] + assert "load_error" not in state["snapshot"]["control"]["warningHistory"][0]["data"] + + replay_state = await service.get_state(context_id="ctx-1", after_sequence=1) + + assert replay_state["events"][0]["eventType"] == "pipeline_warning" + assert replay_state["events"][0]["data"]["reason"] == "cleanup_tracking_unavailable" + assert "ledger_path" not in replay_state["events"][0]["data"] + assert "load_error" not in replay_state["events"][0]["data"] + + @pytest.mark.asyncio async def test_recovery_sanitizes_legacy_artifact_file_uris_from_snapshot_and_replay(tmp_path) -> None: persistence = A2APersistenceStore(tmp_path / "a2a") @@ -245,6 +288,108 @@ async def test_recovery_rejects_task_id_when_pipeline_state_belongs_to_different await service.get_state(task_id="task-2") +@pytest.mark.asyncio +async def test_recovery_resolves_cleanup_snapshot_from_normal_delivery_task_id(tmp_path) -> None: + persistence = A2APersistenceStore(tmp_path / "a2a") + persistence.save_task(A2ATaskSnapshot(task_id="task-pipeline", context_id="ctx-1", state="completed")) + persistence.save_task(A2ATaskSnapshot(task_id="task-normal", context_id="ctx-1", state="input-required")) + persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id="session-1", cwd=str(tmp_path))) + pipeline_dir = SessionStorage().session_dir(str(tmp_path), "session-1") / "pipeline" + raw_error = ( + "DeleteStack failed AccessKeySecret=super-secret token=sk-live-1234567890 " + "at /Users/alice/.iac-code/projects/session/pipeline/cleanup.yaml" + ) + pipeline_started = _event_for_task(1, "evt-pipeline-started", task_id="task-pipeline") + cleanup_started = _event_for_task(2, "evt-cleanup-started", task_id="task-pipeline") + cleanup_started.update( + { + "eventType": "cleanup_started", + "scope": "cleanup", + "deliveryTaskId": "task-normal", + "data": { + "status": "started", + "resourceCount": 1, + "prompt": "hidden cleanup prompt for stack-123", + "ledgerPath": "/Users/alice/.iac-code/projects/session/pipeline/cleanup.yaml", + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-123", + "regionId": "cn-hangzhou", + "cleanupStatus": "started", + "progressStatus": "DELETE_STARTED", + "lastError": raw_error, + }, + } + ) + journal = A2APipelineJournal(pipeline_dir) + journal.append(pipeline_started) + journal.append(cleanup_started) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pipeline_started, cleanup_started])) + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + service = A2APipelineRecoveryService(task_store=store) + + state = await service.get_state(task_id="task-normal", after_sequence=0) + + assert state["snapshot"]["taskId"] == "task-pipeline" + assert state["snapshot"]["cleanup"]["status"] == "started" + assert state["snapshot"]["cleanup"]["resources"][0]["resourceId"] == "stack-123" + assert "prompt" not in state["snapshot"]["cleanup"] + assert "ledgerPath" not in state["snapshot"]["cleanup"] + assert "prompt" not in state["snapshot"]["cleanup"]["history"][0]["data"] + assert "ledgerPath" not in state["snapshot"]["cleanup"]["history"][0]["data"] + assert raw_error not in state["snapshot"]["cleanup"]["history"][0]["data"]["lastError"] + assert [event["eventId"] for event in state["events"]] == ["evt-cleanup-started"] + assert "prompt" not in state["events"][0]["data"] + assert "ledgerPath" not in state["events"][0]["data"] + assert raw_error not in state["events"][0]["data"]["lastError"] + rendered = json.dumps(state, ensure_ascii=False) + assert "super-secret" not in rendered + assert "sk-live-1234567890" not in rendered + assert "/Users/alice" not in rendered + + +@pytest.mark.asyncio +async def test_recovery_by_delivery_task_catches_up_stale_pipeline_snapshot(tmp_path) -> None: + persistence = A2APersistenceStore(tmp_path / "a2a") + persistence.save_task(A2ATaskSnapshot(task_id="task-pipeline", context_id="ctx-1", state="completed")) + persistence.save_task(A2ATaskSnapshot(task_id="task-normal", context_id="ctx-1", state="input-required")) + persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id="session-1", cwd=str(tmp_path))) + pipeline_dir = SessionStorage().session_dir(str(tmp_path), "session-1") / "pipeline" + pipeline_started = _event_for_task(1, "evt-pipeline-started", task_id="task-pipeline") + cleanup_started = _event_for_task(2, "evt-cleanup-started", task_id="task-pipeline") + cleanup_started.update( + { + "eventType": "cleanup_started", + "scope": "cleanup", + "deliveryTaskId": "task-normal", + "data": { + "status": "started", + "resourceCount": 1, + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-123", + "regionId": "cn-hangzhou", + "cleanupStatus": "started", + "progressStatus": "DELETE_STARTED", + }, + } + ) + journal = A2APipelineJournal(pipeline_dir) + journal.append(pipeline_started) + journal.append(cleanup_started) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pipeline_started])) + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + service = A2APipelineRecoveryService(task_store=store) + + state = await service.get_state(task_id="task-normal") + + assert state["snapshot"]["lastSequence"] == 2 + assert state["snapshot"]["cleanup"]["status"] == "started" + assert state["snapshot"]["cleanup"]["resources"][0]["resourceId"] == "stack-123" + assert "prompt" not in state["snapshot"]["cleanup"] + assert "ledgerPath" not in state["snapshot"]["cleanup"] + + @pytest.mark.asyncio async def test_recovery_rejects_context_id_that_does_not_match_task_id(tmp_path) -> None: persistence = A2APersistenceStore(tmp_path / "a2a") diff --git a/tests/a2a/test_pipeline_snapshot.py b/tests/a2a/test_pipeline_snapshot.py index 508bfc32..30ff10f2 100644 --- a/tests/a2a/test_pipeline_snapshot.py +++ b/tests/a2a/test_pipeline_snapshot.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging from pathlib import Path @@ -49,10 +50,10 @@ def test_snapshot_load_logs_parse_failures(tmp_path, caplog) -> None: def test_snapshot_save_cleans_temp_file_when_replace_fails(monkeypatch, tmp_path, caplog) -> None: store = A2APipelineSnapshotStore(tmp_path) - def fail_replace(self: Path, target: Path) -> Path: - raise PermissionError(f"locked: {target}") + def fail_write(path: Path, value: dict, *, durable: bool = True) -> None: + raise PermissionError(f"locked: {path}") - monkeypatch.setattr(Path, "replace", fail_replace) + monkeypatch.setattr(pipeline_snapshot, "atomic_write_json", fail_write) caplog.set_level(logging.WARNING, logger="iac_code.a2a.pipeline_snapshot") assert store.save({"status": "working"}) is False @@ -90,6 +91,26 @@ def test_reduce_steps_and_pending_input() -> None: assert snapshot["pendingInput"]["inputId"] == "input-confirm_and_select-1" +def test_pipeline_warning_does_not_change_terminal_snapshot_status() -> None: + started = _base("evt-start", 1, "pipeline_started") + warning = _base("evt-warning", 2, "pipeline_warning", status="working") + warning["data"] = {"reason": "cleanup_tracking_unavailable"} + + snapshot = reduce_pipeline_events([started, warning]) + + assert snapshot["status"] == "working" + assert snapshot["lastSequence"] == 2 + assert snapshot.get("completedAt") is None + assert snapshot["control"]["warningHistory"] == [ + { + "eventId": "evt-warning", + "sequence": 2, + "createdAt": "2026-06-08T10:00:00Z", + "data": {"reason": "cleanup_tracking_unavailable"}, + } + ] + + def test_reduce_input_received_completes_waiting_step() -> None: step = _base("evt-1", 1, "step_started", scope="step") step["step"] = { @@ -114,6 +135,173 @@ def test_reduce_input_received_completes_waiting_step() -> None: assert snapshot["steps"][0]["completedAt"] == "2026-06-08T10:00:00Z" +def test_reduce_cleanup_handoff_updates_snapshot_cleanup() -> None: + handoff = _base("evt-cleanup-handoff", 1, "pipeline_handoff_ready", status="completed") + handoff["data"] = { + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "completed", + "summary": "[Pipeline Handoff Context]", + "cleanup": { + "status": "pending", + "resourceCount": 1, + "statusMessage": "检测到 1 个回滚残留资源,开始清理流程。", + "resources": [{"resourceId": "stack-123", "regionId": "cn-hangzhou"}], + }, + } + + snapshot = reduce_pipeline_events([handoff]) + + assert snapshot["cleanup"]["status"] == "pending" + assert snapshot["cleanup"]["resourceCount"] == 1 + assert snapshot["cleanup"]["resources"] == [{"resourceId": "stack-123", "regionId": "cn-hangzhou"}] + assert snapshot["cleanup"]["history"][-1]["eventType"] == "pipeline_handoff_ready" + assert snapshot["normalHandoff"]["data"]["cleanup"]["resourceCount"] == 1 + + +def test_reduce_cleanup_progress_events_update_snapshot_cleanup() -> None: + started = _base("evt-cleanup-started", 1, "cleanup_started", scope="cleanup") + started["data"] = { + "status": "started", + "resourceCount": 1, + "resources": [{"resourceId": "stack-123", "regionId": "cn-hangzhou"}], + } + progress = _base("evt-cleanup-progress", 2, "cleanup_progress", scope="cleanup") + progress["data"] = { + "status": "in_progress", + "resourceId": "stack-123", + "regionId": "cn-hangzhou", + "stackStatus": "DELETE_IN_PROGRESS", + } + completed = _base("evt-cleanup-completed", 3, "cleanup_completed", scope="cleanup", status="completed") + completed["data"] = { + "status": "completed", + "resourceId": "stack-123", + "regionId": "cn-hangzhou", + "stackStatus": "DELETE_COMPLETE", + } + + snapshot = reduce_pipeline_events([started, progress, completed]) + + assert snapshot["cleanup"]["status"] == "completed" + assert snapshot["cleanup"]["resourceCount"] == 1 + assert snapshot["cleanup"]["resources"][0]["resourceId"] == "stack-123" + assert snapshot["cleanup"]["resources"][0]["stackStatus"] == "DELETE_COMPLETE" + assert [item["eventType"] for item in snapshot["cleanup"]["history"]] == [ + "cleanup_started", + "cleanup_progress", + "cleanup_completed", + ] + + +def test_reduce_cleanup_status_aggregates_multiple_resources() -> None: + started = _base("evt-cleanup-started", 1, "cleanup_started", scope="cleanup") + started["data"] = { + "status": "pending", + "resourceCount": 2, + "resources": [ + { + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-a", + "regionId": "cn-hangzhou", + "cleanupStatus": "pending", + }, + { + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-b", + "regionId": "cn-hangzhou", + "cleanupStatus": "pending", + }, + ], + } + completed_one = _base("evt-cleanup-one-complete", 2, "cleanup_completed", scope="cleanup") + completed_one["data"] = { + "status": "completed", + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-a", + "regionId": "cn-hangzhou", + "cleanupStatus": "completed", + "stackStatus": "DELETE_COMPLETE", + } + failed_one = _base("evt-cleanup-one-failed", 3, "cleanup_failed", scope="cleanup") + failed_one["data"] = { + "status": "failed", + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-b", + "regionId": "cn-hangzhou", + "cleanupStatus": "failed", + "stackStatus": "DELETE_FAILED", + } + + partial = reduce_pipeline_events([started, completed_one]) + failed = reduce_pipeline_events([started, completed_one, failed_one]) + + assert partial["cleanup"]["status"] == "pending" + assert failed["cleanup"]["status"] == "failed" + + +def test_reduce_cleanup_progress_distinguishes_provider_and_resource_type() -> None: + started = _base("evt-cleanup-started", 1, "cleanup_started", scope="cleanup") + started["data"] = { + "status": "started", + "resourceCount": 3, + "resources": [ + { + "provider": "ros", + "resourceType": "stack", + "resourceId": "shared-id", + "regionId": "cn-hangzhou", + "stackStatus": "DELETE_IN_PROGRESS", + }, + { + "provider": "ros", + "resourceType": "stack_set", + "resourceId": "shared-id", + "regionId": "cn-hangzhou", + "stackStatus": "DELETE_IN_PROGRESS", + }, + { + "provider": "terraform", + "resourceType": "stack", + "resourceId": "shared-id", + "regionId": "cn-hangzhou", + "stackStatus": "DELETE_IN_PROGRESS", + }, + ], + } + type_progress = _base("evt-cleanup-type-progress", 2, "cleanup_progress", scope="cleanup") + type_progress["data"] = { + "status": "in_progress", + "provider": "ros", + "resourceType": "stack_set", + "resourceId": "shared-id", + "regionId": "cn-hangzhou", + "stackStatus": "DELETE_COMPLETE", + } + provider_progress = _base("evt-cleanup-provider-progress", 3, "cleanup_progress", scope="cleanup") + provider_progress["data"] = { + "status": "in_progress", + "provider": "terraform", + "resourceType": "stack", + "resourceId": "shared-id", + "regionId": "cn-hangzhou", + "stackStatus": "DELETE_FAILED", + } + + snapshot = reduce_pipeline_events([started, type_progress, provider_progress]) + + resources = { + (resource["provider"], resource["resourceType"]): resource for resource in snapshot["cleanup"]["resources"] + } + assert resources[("ros", "stack")]["stackStatus"] == "DELETE_IN_PROGRESS" + assert resources[("ros", "stack_set")]["stackStatus"] == "DELETE_COMPLETE" + assert resources[("terraform", "stack")]["stackStatus"] == "DELETE_FAILED" + + def test_reduce_input_received_records_candidate_selection_details_on_step() -> None: step = _base("evt-1", 1, "step_started", scope="step") step["step"] = { @@ -729,6 +917,7 @@ def test_reduce_stack_current_changed_updates_snapshot_stack_state() -> None: "regionId": "cn-hangzhou", "stackId": "stack-123", "stackName": "demo", + "stackStatus": "DELETE_COMPLETE", "isSuccess": True, "current": False, "cleared": True, @@ -744,6 +933,40 @@ def test_reduce_stack_current_changed_updates_snapshot_stack_state() -> None: assert [item["eventId"] for item in deleted_snapshot["stacks"]["history"]] == ["evt-create", "evt-delete"] +def test_reduce_stack_current_changed_keeps_current_for_delete_requested() -> None: + created = _base("evt-create", 1, "stack_current_changed", scope="stack") + created["data"] = { + "toolName": "aliyun_api", + "toolUseId": "toolu-create", + "provider": "ros", + "action": "CreateStack", + "regionId": "cn-hangzhou", + "stackId": "stack-123", + "stackName": "demo", + "isSuccess": True, + "current": True, + } + delete_requested = _base("evt-delete-requested", 2, "stack_current_changed", scope="stack") + delete_requested["data"] = { + "toolName": "ros_stack", + "toolUseId": "toolu-delete", + "provider": "ros", + "action": "DeleteStack", + "regionId": "cn-hangzhou", + "stackId": "stack-123", + "stackName": "demo", + "stackStatus": "DELETE_REQUESTED", + "isSuccess": True, + "current": True, + } + + snapshot = reduce_pipeline_events([created, delete_requested]) + + assert snapshot["stacks"]["current"]["stackId"] == "stack-123" + assert snapshot["stacks"]["byId"]["stack-123"]["current"] is True + assert snapshot["stacks"]["byId"]["stack-123"]["stackStatus"] == "DELETE_REQUESTED" + + def test_reduce_artifact_created_prefers_top_level_artifact_metadata() -> None: artifact = _base("evt-1", 1, "artifact_created", scope="step") artifact["step"] = {"runId": "step-a-1", "id": "a", "index": 1, "total": 1, "attempt": 1} @@ -940,6 +1163,81 @@ def test_store_sanitizes_non_finite_and_non_json_values(tmp_path) -> None: assert loaded["display"]["candidateDetails"][0]["raw"].startswith(" None: + store = A2APipelineSnapshotStore(tmp_path / "pipeline") + raw_error = ( + "DeleteStack failed AccessKeySecret=super-secret token=sk-live-1234567890 " + "at /Users/alice/.iac-code/projects/session/pipeline/cleanup.yaml" + ) + snapshot = reduce_pipeline_events([_base("evt-1", 1, "pipeline_started")]) + snapshot["pendingInput"] = {"prompt": "choose deployment target"} + snapshot["control"]["inputHistory"] = [{"prompt": "choose deployment target"}] + snapshot["control"]["handoffHistory"] = [ + { + "data": { + "cleanup": { + "prompt": "hidden cleanup prompt", + "ledgerPath": "/tmp/cleanup.yaml", + "lastError": raw_error, + } + } + } + ] + snapshot["normalHandoff"] = { + "data": { + "cleanup": { + "prompt": "hidden cleanup prompt", + "ledgerPath": "/tmp/cleanup.yaml", + "lastError": raw_error, + } + } + } + snapshot["cleanup"] = { + "status": "pending", + "resourceCount": 1, + "resources": [{"resourceId": "stack-123", "lastError": raw_error}], + "history": [ + {"data": {"prompt": "hidden cleanup prompt", "ledgerPath": "/tmp/cleanup.yaml", "lastError": raw_error}} + ], + "prompt": "hidden cleanup prompt", + "ledgerPath": "/tmp/cleanup.yaml", + "last_error": raw_error, + } + + store.save(snapshot) + + loaded = store.load() + assert loaded is not None + assert loaded["pendingInput"]["prompt"] == "choose deployment target" + assert loaded["control"]["inputHistory"][0]["prompt"] == "choose deployment target" + assert "prompt" not in loaded["control"]["handoffHistory"][0]["data"]["cleanup"] + assert raw_error not in loaded["control"]["handoffHistory"][0]["data"]["cleanup"]["lastError"] + assert "ledgerPath" not in loaded["normalHandoff"]["data"]["cleanup"] + assert raw_error not in loaded["normalHandoff"]["data"]["cleanup"]["lastError"] + assert "prompt" not in loaded["cleanup"] + assert raw_error not in loaded["cleanup"]["last_error"] + assert raw_error not in loaded["cleanup"]["resources"][0]["lastError"] + assert "ledgerPath" not in loaded["cleanup"]["history"][0]["data"] + assert raw_error not in loaded["cleanup"]["history"][0]["data"]["lastError"] + rendered = json.dumps(loaded, ensure_ascii=False) + assert "super-secret" not in rendered + assert "sk-live-1234567890" not in rendered + assert "/Users/alice" not in rendered + assert "[REDACTED]" in rendered + assert "[PATH]" in rendered + + store.path.write_text(json.dumps(snapshot), encoding="utf-8") + loaded = store.load() + assert loaded is not None + assert loaded["pendingInput"]["prompt"] == "choose deployment target" + assert "prompt" not in loaded["normalHandoff"]["data"]["cleanup"] + assert "ledgerPath" not in loaded["cleanup"] + rendered = json.dumps(loaded, ensure_ascii=False) + assert "super-secret" not in rendered + assert "sk-live-1234567890" not in rendered + assert "/Users/alice" not in rendered + + def test_store_returns_none_for_invalid_utf8_snapshot(tmp_path) -> None: store = A2APipelineSnapshotStore(tmp_path / "pipeline") store.pipeline_dir.mkdir(parents=True) diff --git a/tests/a2a/test_pipeline_stream.py b/tests/a2a/test_pipeline_stream.py index ca1d1f65..3a14f93c 100644 --- a/tests/a2a/test_pipeline_stream.py +++ b/tests/a2a/test_pipeline_stream.py @@ -15,7 +15,7 @@ from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore -from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher +from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher, is_recovery_semantic_event from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.types.stream_events import ( AskUserQuestionEvent, @@ -81,6 +81,17 @@ def _envelope(event_type: str, status: str = "working") -> dict[str, Any]: } +def test_pipeline_warning_is_recovery_semantic() -> None: + assert is_recovery_semantic_event(_envelope("pipeline_warning")) is True + + +def test_unknown_working_step_event_is_recovery_semantic() -> None: + envelope = _envelope("custom_step_progress") + envelope["scope"] = "step" + + assert is_recovery_semantic_event(envelope) is True + + @pytest.mark.asyncio async def test_publish_text_writes_a2a_metadata_journal_and_snapshot(tmp_path: Path) -> None: publisher, queue = _publisher(tmp_path) @@ -300,7 +311,7 @@ async def test_publish_permission_denies_future_when_permission_metadata_is_not_ publisher, queue = _publisher(tmp_path) future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - def fail_append(_event: dict[str, Any]) -> None: + def fail_append(_event: dict[str, Any], durable: bool = False) -> None: raise OSError("append failed") def fail_save(_snapshot: dict[str, Any]) -> bool: @@ -324,6 +335,91 @@ def fail_save(_snapshot: dict[str, Any]) -> bool: assert queue.events == [] +@pytest.mark.asyncio +async def test_recovery_semantic_event_is_not_enqueued_when_metadata_persistence_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, queue = _publisher(tmp_path) + + def fail_append(_event: dict[str, Any], durable: bool = False) -> None: + raise OSError("journal locked") + + monkeypatch.setattr(publisher.journal, "append", fail_append) + monkeypatch.setattr(publisher.snapshot_store, "save", lambda _snapshot: False) + + result = await publisher.publish_manual("pipeline_started", "pipeline") + + assert result is None + assert queue.events == [] + + +@pytest.mark.asyncio +async def test_text_delta_can_be_enqueued_when_only_durable_metadata_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, queue = _publisher(tmp_path) + + def fail_append(event: dict[str, Any], durable: bool = False) -> None: + if durable: + raise OSError("journal locked") + A2APipelineJournal.append(publisher.journal, event) + + monkeypatch.setattr(publisher.journal, "append", fail_append) + monkeypatch.setattr(publisher.snapshot_store, "save", lambda _snapshot: False) + + returned = await publisher.publish(TextDeltaEvent(text="hello")) + + assert returned == "hello" + assert len(queue.events) == 1 + + +@pytest.mark.asyncio +async def test_manual_recovery_event_routes_durable_metadata_without_explicit_request( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, _queue = _publisher(tmp_path) + durable_flags: list[bool] = [] + + def record_append(event: dict[str, Any], durable: bool = False) -> None: + durable_flags.append(durable) + A2APipelineJournal.append(publisher.journal, event) + + monkeypatch.setattr(publisher.journal, "append", record_append) + + await publisher.publish_manual("pipeline_started", "pipeline") + + assert durable_flags == [True] + + +@pytest.mark.asyncio +async def test_translated_recovery_event_routes_durable_metadata_without_explicit_request( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, _queue = _publisher(tmp_path) + durable_flags: list[bool] = [] + + def record_append(event: dict[str, Any], durable: bool = False) -> None: + durable_flags.append(durable) + A2APipelineJournal.append(publisher.journal, event) + + monkeypatch.setattr(publisher.journal, "append", record_append) + + await publisher.publish( + PipelineEvent( + type=PipelineEventType.STEP_STARTED, + step_id="confirm_and_select", + timestamp=1717821600.0, + data={"index": 1, "total": 2}, + ) + ) + + assert durable_flags == [True] + + @pytest.mark.asyncio async def test_publish_permission_redacts_and_truncates_tool_input_in_status_metadata_and_journal( tmp_path: Path, @@ -702,7 +798,7 @@ async def test_publish_does_not_emit_artifact_update_when_artifact_metadata_is_n store = A2AArtifactStore(tmp_path / "artifacts") publisher, queue = _publisher(tmp_path, artifact_store=store, exposure_types=[A2AExposureType.TOOL_TRACE]) - def fail_append(_event: dict[str, Any]) -> None: + def fail_append(_event: dict[str, Any], durable: bool = False) -> None: raise OSError("append failed") def fail_save(_snapshot: dict[str, Any]) -> None: @@ -896,7 +992,7 @@ async def test_publish_candidate_failure_keeps_a2a_task_working(tmp_path: Path) async def test_publish_continues_when_pipeline_persistence_fails(tmp_path: Path) -> None: publisher, queue = _publisher(tmp_path) - def fail_append(_event: dict[str, Any]) -> None: + def fail_append(_event: dict[str, Any], durable: bool = False) -> None: raise OSError("disk full") publisher.journal.append = fail_append # type: ignore[method-assign] @@ -984,7 +1080,7 @@ async def test_publish_rebuilds_missing_snapshot_with_current_event_when_journal await publisher.publish(TextDeltaEvent(text="old")) publisher.snapshot_store.path.unlink() - def fail_append(_event: dict[str, Any]) -> None: + def fail_append(_event: dict[str, Any], durable: bool = False) -> None: raise OSError("disk full") publisher.journal.append = fail_append # type: ignore[method-assign] @@ -1115,6 +1211,25 @@ async def test_publish_ask_user_question_maps_to_input_required_snapshot(tmp_pat assert snapshot["pendingInput"]["question"] == "请选择部署目标" +@pytest.mark.asyncio +async def test_pipeline_input_received_is_not_enqueued_when_metadata_persistence_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, queue = _publisher(tmp_path) + + def fail_append(_event: dict[str, Any], durable: bool = False) -> None: + raise OSError("journal locked") + + monkeypatch.setattr(publisher.journal, "append", fail_append) + monkeypatch.setattr(publisher.snapshot_store, "save", lambda _snapshot: False) + + result = await publisher.publish_manual("input_received", "pipeline") + + assert result is None + assert queue.events == [] + + @pytest.mark.asyncio @pytest.mark.parametrize( ("failed", "expected_state"), diff --git a/tests/a2a/test_selling_console_frontend.py b/tests/a2a/test_selling_console_frontend.py new file mode 100644 index 00000000..b175b305 --- /dev/null +++ b/tests/a2a/test_selling_console_frontend.py @@ -0,0 +1,4893 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +APP_JS = Path(__file__).resolve().parents[2] / "scripts" / "a2a" / "selling_console_web" / "app.js" +STYLES_CSS = APP_JS.parent / "styles.css" +NODE_RELATIVE_PATH = Path(".cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node") + + +def bundled_node_candidates() -> list[Path]: + override = os.environ.get("IAC_CODE_TEST_NODE") + if override: + return [Path(override).expanduser()] + candidates = [Path.home() / NODE_RELATIVE_PATH] + home_env = os.environ.get("HOME") + if home_env: + candidates.append(Path(home_env).expanduser() / NODE_RELATIVE_PATH) + candidates.extend(parent / NODE_RELATIVE_PATH for parent in APP_JS.parents) + return candidates + + +def node_command() -> list[str]: + node = shutil.which("node") + if node: + return [node] + for fallback in bundled_node_candidates(): + if fallback.exists(): + return [str(fallback)] + pytest.skip("node is not installed") + + +def run_node_script(source: str) -> dict: + with tempfile.TemporaryDirectory(prefix="iac-code-selling-console-test-") as temp_dir: + script_path = Path(temp_dir) / "script.js" + script_path.write_text(source, encoding="utf-8") + result = subprocess.run( + [*node_command(), str(script_path)], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_run_node_script_uses_file_instead_of_inline_eval(monkeypatch: pytest.MonkeyPatch) -> None: + source = 'console.log(JSON.stringify({"ok": true}));' + command_seen: list[str] = [] + + def fake_run(command, *, capture_output, text, check, encoding): + command_seen.extend(str(part) for part in command) + assert capture_output is True + assert text is True + assert check is False + assert encoding == "utf-8" + assert "-e" not in command_seen + script_path = Path(command_seen[-1]) + assert script_path.read_text(encoding="utf-8") == source + return subprocess.CompletedProcess(command, 0, stdout='{"ok": true}\n', stderr="") + + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None) + monkeypatch.setattr(subprocess, "run", fake_run) + + assert run_node_script(source) == {"ok": True} + assert command_seen[:1] == ["/usr/bin/node"] + + +def test_node_command_falls_back_to_home_bundled_node_when_path_is_empty( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + fake_node = tmp_path / NODE_RELATIVE_PATH + fake_node.parent.mkdir(parents=True) + fake_node.write_text("#!/bin/sh\n", encoding="utf-8") + fake_node.chmod(0o755) + monkeypatch.setenv("PATH", "") + monkeypatch.delenv("IAC_CODE_TEST_NODE", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert shutil.which("node") is None + + command = node_command() + + assert command == [str(fake_node)] + assert Path(command[0]).exists() + + +def test_node_command_uses_env_override_when_path_is_empty(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + fake_node = tmp_path / "node" + fake_node.write_text("#!/bin/sh\n", encoding="utf-8") + fake_node.chmod(0o755) + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("IAC_CODE_TEST_NODE", str(fake_node)) + + command = node_command() + + assert command == [str(fake_node)] + + +def reducer_harness(expression: str) -> dict: + app_source = APP_JS.read_text(encoding="utf-8") + script = f""" +const assert = require("assert"); +global.window = {{}}; +global.document = {{ + readyState: "loading", + addEventListener() {{}}, + querySelector() {{ return null; }}, + querySelectorAll() {{ return []; }}, + getElementById() {{ return null; }} +}}; +{app_source} +const reducers = window.SellingConsoleReducers; +const output = (() => {{ + {expression} +}})(); +console.log(JSON.stringify(output)); +""" + return run_node_script(script) + + +def controller_harness(expression: str) -> dict: + app_source = APP_JS.read_text(encoding="utf-8") + script = f""" +class FakeElement {{ + constructor(tagName, id = "") {{ + this.tagName = tagName.toUpperCase(); + this.id = id; + this.children = []; + this.attributes = {{}}; + this.listeners = {{}}; + this.className = ""; + this.textContent = ""; + this.value = ""; + this.hidden = false; + this.scrollTop = 0; + this.scrollHeight = 100; + this.clientHeight = 30; + }} + appendChild(child) {{ + this.children.push(child); + return child; + }} + replaceChildren(...children) {{ + this.children = children; + this.textContent = ""; + }} + setAttribute(name, value) {{ + this.attributes[name] = String(value); + }} + getAttribute(name) {{ + return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; + }} + addEventListener(name, listener) {{ + this.listeners[name] = this.listeners[name] || []; + this.listeners[name].push(listener); + }} + click() {{ + (this.listeners.click || []).forEach((listener) => listener({{type: "click"}})); + }} +}} +function walk(element, callback) {{ + if (!element) {{ + return; + }} + callback(element); + (element.children || []).forEach((child) => walk(child, callback)); +}} +function textOf(element) {{ + if (!element) {{ + return ""; + }} + return [element.textContent || "", ...(element.children || []).map(textOf)].join(""); +}} +const elements = {{ + "step-list": new FakeElement("div", "step-list"), + "composer-progress": new FakeElement("div", "composer-progress"), + "debug-drawer": new FakeElement("details", "debug-drawer"), + "progress-debug-panel": new FakeElement("div", "progress-debug-panel"), + "debug-output": new FakeElement("pre", "debug-output"), + "debug-session-info": new FakeElement("div", "debug-session-info"), + "normal-handoff-notice": new FakeElement("div", "normal-handoff-notice"), + "plans-grid": new FakeElement("div", "plans-grid"), + "status-pill": new FakeElement("span", "status-pill"), + "status-alert": new FakeElement("div", "status-alert"), + "server-url": new FakeElement("input", "server-url"), + cwd: new FakeElement("input", "cwd"), + "composer-input": new FakeElement("textarea", "composer-input"), + "send-button": new FakeElement("button", "send-button"), + "health-button": new FakeElement("button", "health-button"), + "fetch-state-button": new FakeElement("button", "fetch-state-button"), + "cancel-button": new FakeElement("button", "cancel-button"), +}}; +elements["normal-handoff-notice"].hidden = true; +const debugPre = elements["debug-output"]; +const roots = Object.values(elements); +global.window = {{SELLING_CONSOLE_DEFAULTS: {{serverUrl: "http://127.0.0.1:41299", cwd: "/workspace"}}}}; +global.document = {{ + readyState: "loading", + addEventListener() {{}}, + createElement(tagName) {{ return new FakeElement(tagName); }}, + getElementById(id) {{ return elements[id] || null; }}, + querySelector(selector) {{ + if (selector === "#debug-drawer pre") {{ + return debugPre; + }} + if (selector.startsWith("#")) {{ + return elements[selector.slice(1)] || null; + }} + return null; + }}, + querySelectorAll(selector) {{ + const matches = []; + roots.forEach((root) => walk(root, (element) => {{ + if (selector === "[data-step-id]" && element.getAttribute("data-step-id") !== null) {{ + matches.push(element); + }} + if (selector === "[data-step-event-kind]" && element.getAttribute("data-step-event-kind") !== null) {{ + matches.push(element); + }} + if (selector === "[data-step-state-icon]" && element.getAttribute("data-step-state-icon") !== null) {{ + matches.push(element); + }} + if (selector === "[data-step-toggle]" && element.getAttribute("data-step-toggle") !== null) {{ + matches.push(element); + }} + if (selector === "[data-step-result-field]" && element.getAttribute("data-step-result-field") !== null) {{ + matches.push(element); + }} + if (selector === "[data-step-result-option]" && element.getAttribute("data-step-result-option") !== null) {{ + matches.push(element); + }} + if (selector === "[data-step-candidate-result]" && element.getAttribute("data-step-candidate-result") !== null) {{ + matches.push(element); + }} + if ( + selector === "[data-step-candidate-result-summary]" && + element.getAttribute("data-step-candidate-result-summary") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-step-candidate-result-process]" && + element.getAttribute("data-step-candidate-result-process") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-step-candidate-progress]" && + element.getAttribute("data-step-candidate-progress") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-step-candidate-progress-head]" && + element.getAttribute("data-step-candidate-progress-head") !== null + ) {{ + matches.push(element); + }} + if (selector === "[data-pending-input-kind]" && element.getAttribute("data-pending-input-kind") !== null) {{ + matches.push(element); + }} + if (selector === "[data-pending-input-option]" && element.getAttribute("data-pending-input-option") !== null) {{ + matches.push(element); + }} + if (selector === "[data-progress-step]" && element.getAttribute("data-progress-step") !== null) {{ + matches.push(element); + }} + if ( + selector === "[data-progress-variant-option]" && + element.getAttribute("data-progress-variant-option") !== null + ) {{ + matches.push(element); + }} + if (selector === "[data-progress-param]" && element.getAttribute("data-progress-param") !== null) {{ + matches.push(element); + }} + if (selector === "[data-progress-param-group]" && element.getAttribute("data-progress-param-group") !== null) {{ + matches.push(element); + }} + if (selector === "[data-progress-step-option]" && element.getAttribute("data-progress-step-option") !== null) {{ + matches.push(element); + }} + if (selector === "[data-candidate-choice]" && element.getAttribute("data-candidate-choice") !== null) {{ + matches.push(element); + }} + if (selector === "[data-candidate-index]" && element.getAttribute("data-candidate-index") !== null) {{ + matches.push(element); + }} + if (selector === "[data-candidate-status]" && element.getAttribute("data-candidate-status") !== null) {{ + matches.push(element); + }} + if ( + selector === "[data-candidate-subpipeline]" && + element.getAttribute("data-candidate-subpipeline") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-candidate-subpipeline-body]" && + element.getAttribute("data-candidate-subpipeline-body") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-candidate-subpipeline-event]" && + element.getAttribute("data-candidate-subpipeline-event") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-candidate-subpipeline-toggle]" && + element.getAttribute("data-candidate-subpipeline-toggle") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-candidate-substep]" && + element.getAttribute("data-candidate-substep") !== null + ) {{ + matches.push(element); + }} + if (selector === "[data-step-process]" && element.getAttribute("data-step-process") !== null) {{ + matches.push(element); + }} + if (selector === "[data-step-event-list]" && element.getAttribute("data-step-event-list") !== null) {{ + matches.push(element); + }} + if ( + selector === "[data-step-process-event]" && + element.getAttribute("data-step-process-event") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-debug-session-field]" && + element.getAttribute("data-debug-session-field") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-normal-handoff-message]" && + element.getAttribute("data-normal-handoff-message") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-chat-message]" && + element.getAttribute("data-chat-message") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-chat-avatar]" && + element.getAttribute("data-chat-avatar") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-normal-turn]" && + element.getAttribute("data-normal-turn") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-normal-process]" && + element.getAttribute("data-normal-process") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-normal-process-event]" && + element.getAttribute("data-normal-process-event") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-normal-answer]" && + element.getAttribute("data-normal-answer") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-markdown-node]" && + element.getAttribute("data-markdown-node") !== null + ) {{ + matches.push(element); + }} + if ( + selector === "[data-template-popover]" && + element.getAttribute("data-template-popover") !== null + ) {{ + matches.push(element); + }} + }})); + return matches; + }}, +}}; +{app_source} +(async () => {{ + const output = await (async () => {{ + const controller = window.SellingConsoleController; + const debug = window.SellingConsoleDebug; + const reducers = window.SellingConsoleReducers; + const elementById = (id) => elements[id]; + const all = (selector) => document.querySelectorAll(selector); + const text = textOf; + const debugText = () => debugPre.textContent; + {expression} + }})(); + console.log(JSON.stringify(output)); +}})().catch((error) => {{ + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}}); +""" + return run_node_script(script) + + +def test_reducer_maps_pipeline_steps_to_console_sections() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({serverUrl: "http://127.0.0.1:41299", cwd: "/workspace"}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + taskId: "task-1", + contextId: "ctx-1", + sequence: 3, + step: {id: "architecture_planning", name: "架构规划", status: "completed"} + }}} +}); +return { + taskId: next.pipelineTaskId, + contextId: next.contextId, + sequence: next.lastSequence, + architectureStatus: next.steps.architecture_planning.status +}; +""" + ) + + assert output == { + "taskId": "task-1", + "contextId": "ctx-1", + "sequence": 3, + "architectureStatus": "completed", + } + + +def test_reducer_uses_event_type_to_mark_completed_step_when_envelope_status_is_working() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + step: {id: "intent_parsing"}, + data: { + conclusion: { + scenario: "Nginx 静态站点", + region: "华东 1(杭州)", + budget: "低成本" + } + } + }}} +}); +return { + status: next.steps.intent_parsing.status, + eventCount: next.steps.intent_parsing.events.length +}; +""" + ) + + assert output == { + "status": "completed", + "eventCount": 1, + } + + +def test_reducer_keeps_parent_step_working_when_candidate_sub_step_completes() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.steps.evaluate_candidates.status = "working"; +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_completed", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "cost_estimating"}, + data: {summary: "候选方案费用已估算"} + }}} +}); +return { + status: next.steps.evaluate_candidates.status, + eventCount: next.steps.evaluate_candidates.events.length +}; +""" + ) + + assert output == { + "status": "working", + "eventCount": 1, + } + + +def test_reducer_collects_candidate_details_from_tool_display() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState(); +const next = reducers.reducePipelinePayload(state, { + snapshot: { + status: "waiting_input", + display: { + candidateDetails: [{ + candidateName: "ECS 经典网络方案", + candidateIndex: 0, + summary: "VPC + ECS + EIP", + totalMonthlyCost: "¥33.89/月", + costItems: [{name: "ECS", spec: "1vCPU/1GiB", monthly_cost: "¥33.89/月"}] + }] + }, + pendingInput: { + kind: "ask_user_question", + prompt: "请选择方案", + options: [{id: "0", label: "ECS 经典网络方案"}] + } + } +}); +return { + candidateCount: next.candidates.length, + candidateName: next.candidates[0].name, + candidateCost: next.candidates[0].totalMonthlyCost, + pendingPrompt: next.pendingInput.prompt +}; +""" + ) + + assert output == { + "candidateCount": 1, + "candidateName": "ECS 经典网络方案", + "candidateCost": "¥33.89/月", + "pendingPrompt": "请选择方案", + } + + +def test_reducer_preserves_zero_candidate_total_monthly_cost() -> None: + output = reducer_harness( + """ +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + snapshot: { + display: { + candidateDetails: [{ + candidateName: "免费方案", + candidateIndex: 0, + totalMonthlyCost: 0 + }] + } + } +}); +return { + totalMonthlyCost: next.candidates[0].totalMonthlyCost +}; +""" + ) + + assert output == {"totalMonthlyCost": 0} + + +def test_reducer_collects_candidate_details_from_detail_wrapper() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const next = reducers.reducePipelinePayload(state, { + snapshot: { + status: "waiting_input", + display: { + candidateDetails: [{ + detailId: "detail-1", + candidate: {index: 0}, + step: {id: "confirm_and_select"}, + detail: { + candidateName: "低成本 ECS 方案", + summary: "single ecs", + totalMonthlyCost: "CNY 60", + costItems: [{name: "ecs", monthly_cost: "CNY 60"}] + } + }] + } + } +}); +return { + candidateCount: next.candidates.length, + firstName: next.candidates[0].name, + firstIndex: next.candidates[0].candidateIndex, + firstSummary: next.candidates[0].summary, + firstCost: next.candidates[0].totalMonthlyCost, + firstCostItemName: next.candidates[0].costItems[0].name +}; +""" + ) + + assert output == { + "candidateCount": 1, + "firstName": "低成本 ECS 方案", + "firstIndex": 0, + "firstSummary": "single ecs", + "firstCost": "CNY 60", + "firstCostItemName": "ecs", + } + + +def test_reducer_collects_candidate_options_from_complete_step_conclusion() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + step: {id: "complete_step", status: "completed"}, + data: { + conclusion: { + options: [{ + title: "轻量应用服务器一体化方案", + index: 1, + summary: "开箱即用,管理简单。", + totalMonthlyCost: "¥0/月" + }] + } + } + }}} +}); +return { + count: next.candidates.length, + name: next.candidates[0] && next.candidates[0].name, + index: next.candidates[0] && next.candidates[0].candidateIndex, + cost: next.candidates[0] && next.candidates[0].totalMonthlyCost +}; +""" + ) + + assert output == { + "count": 1, + "name": "轻量应用服务器一体化方案", + "index": 1, + "cost": "¥0/月", + } + + +def test_reducer_populates_candidate_summary_and_price_from_nested_candidate_payload() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + step: {id: "architecture_planning", status: "completed"}, + data: { + conclusion: { + candidates: [{ + index: 0, + template: "创建基础 VPC 专有网络", + candidate: { + output_path: "templates/1-basic-vpc.yml", + pros: "满足基础网络隔离需求、零成本、可按需扩展子网和安全组", + cons: "仅含 VPC,需后续手动添加 VSwitch", + monthly_estimate: 0 + }, + cost: { + monthly_estimate: "¥0/月", + currency: "CNY" + } + }] + } + } + }}} +}); +return { + count: next.candidates.length, + name: next.candidates[0].name, + summary: next.candidates[0].summary, + totalMonthlyCost: next.candidates[0].totalMonthlyCost, + outputPath: next.candidates[0].outputPath +}; +""" + ) + + assert output == { + "count": 1, + "name": "创建基础 VPC 专有网络", + "summary": "满足基础网络隔离需求、零成本、可按需扩展子网和安全组", + "totalMonthlyCost": "¥0/月", + "outputPath": "templates/1-basic-vpc.yml", + } + + +def test_reducer_collects_step_two_draft_candidates_from_architecture_completion() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + step: {id: "architecture_planning", status: "completed"}, + data: { + conclusion: { + draft_candidates: [{ + candidate_index: 0, + candidate_name: "基础 VPC 网络", + first_version_description: "创建一个基础 VPC,作为后续云资源的网络容器。", + rough_monthly_estimate: "¥0/月" + }] + } + } + }}} +}); +return { + count: next.candidates.length, + name: next.candidates[0] && next.candidates[0].name, + summary: next.candidates[0] && next.candidates[0].summary, + cost: next.candidates[0] && next.candidates[0].totalMonthlyCost +}; +""" + ) + + assert output == { + "count": 1, + "name": "基础 VPC 网络", + "summary": "创建一个基础 VPC,作为后续云资源的网络容器。", + "cost": "¥0/月", + } + + +def test_reducer_updates_candidate_summary_and_price_from_candidate_completed_event() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.candidates = [{candidateIndex: 0, name: "基础 VPC 网络"}]; +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "candidate_completed", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: { + candidate_name: "基础 VPC 网络", + summary: "VPC 本身免费,适合作为后续子网和云资源的基础容器。", + total_monthly_cost: "¥0/月" + } + }}} +}); +return { + count: next.candidates.length, + name: next.candidates[0].name, + summary: next.candidates[0].summary, + cost: next.candidates[0].totalMonthlyCost, + subEventKind: next.candidates[0].subEvents[0].eventType +}; +""" + ) + + assert output == { + "count": 1, + "name": "基础 VPC 网络", + "summary": "VPC 本身免费,适合作为后续子网和云资源的基础容器。", + "cost": "¥0/月", + "subEventKind": "candidate_completed", + } + + +def test_reducer_updates_candidate_from_nested_candidate_completed_conclusions() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.candidates = [{ + candidateIndex: 0, + name: "经济型演示方案", + summary: "成本最低,适合个人演示场景", + totalMonthlyCost: "¥50 - ¥80" +}]; +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "candidate_completed", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0, name: "经济型演示方案"}, + data: { + candidateIndex: 0, + candidateName: "经济型演示方案", + conclusions: { + template: { + file_path: "templates/1-economy-nginx.yml", + description: "经济型 Nginx 演示环境 - VPC 内单可用区部署一台 ECS。" + }, + cost: { + monthly_estimate: "¥74/月", + resources: [ + {type: "ECS 实例", cost: "¥34/月"}, + {type: "系统盘", cost: "¥40/月"} + ] + } + } + } + }}} +}); +return { + count: next.candidates.length, + name: next.candidates[0].name, + summary: next.candidates[0].summary, + cost: next.candidates[0].totalMonthlyCost, + outputPath: next.candidates[0].outputPath, + costItemCount: next.candidates[0].costItems.length +}; +""" + ) + + assert output == { + "count": 1, + "name": "经济型演示方案", + "summary": "经济型 Nginx 演示环境 - VPC 内单可用区部署一台 ECS。", + "cost": "¥74/月", + "outputPath": "templates/1-economy-nginx.yml", + "costItemCount": 2, + } + + +def test_reducer_collects_snake_case_candidate_index_from_conclusion_options() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + step: {id: "complete_step", status: "completed"}, + data: { + conclusion: { + options: [{ + title: "低成本 ECS 方案", + candidate_index: 3, + total_monthly_cost: "¥33.89/月" + }] + } + } + }}} +}); +reducers.selectCandidate(next, next.candidates[0].candidateIndex); +return { + count: next.candidates.length, + index: next.candidates[0].candidateIndex, + cost: next.candidates[0].totalMonthlyCost, + prompt: reducers.promptForSelectedCandidate(next) +}; +""" + ) + + assert output == { + "count": 1, + "index": 3, + "cost": "¥33.89/月", + "prompt": "选择方案3", + } + + +def test_reducer_does_not_mutate_original_state() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({serverUrl: "http://server", cwd: "/workspace"}); +const originalStep = state.steps.architecture_planning; +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + taskId: "task-1", + contextId: "ctx-1", + sequence: 1, + step: {id: "architecture_planning", status: "completed"} + }}} +}); +return { + sameState: next === state, + sameSteps: next.steps === state.steps, + sameStep: next.steps.architecture_planning === originalStep, + originalTaskId: state.pipelineTaskId, + originalStepStatus: state.steps.architecture_planning.status, + originalEventCount: state.steps.architecture_planning.events.length, + nextTaskId: next.pipelineTaskId, + nextStepStatus: next.steps.architecture_planning.status, + nextEventCount: next.steps.architecture_planning.events.length +}; +""" + ) + + assert output == { + "sameState": False, + "sameSteps": False, + "sameStep": False, + "originalTaskId": "", + "originalStepStatus": "pending", + "originalEventCount": 0, + "nextTaskId": "task-1", + "nextStepStatus": "completed", + "nextEventCount": 1, + } + + +def test_reducer_collects_realtime_candidate_detail_event() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + taskId: "task-1", + contextId: "ctx-1", + sequence: 7, + step: {id: "confirm_and_select", status: "working"}, + candidate: {index: 0}, + data: { + detailId: "detail-1", + detail: { + candidateName: "低成本 ECS 方案", + summary: "single ecs", + totalMonthlyCost: "CNY 60", + costItems: [{name: "ecs", monthly_cost: "CNY 60"}] + } + } + }}} +}); +return { + count: next.candidates.length, + name: next.candidates[0].name, + index: next.candidates[0].candidateIndex, + cost: next.candidates[0].totalMonthlyCost, + eventCount: next.steps.confirm_and_select.events.length +}; +""" + ) + + assert output == { + "count": 1, + "name": "低成本 ECS 方案", + "index": 0, + "cost": "CNY 60", + "eventCount": 1, + } + + +def test_reducer_does_not_retain_mutable_candidate_event_payload_references() -> None: + output = reducer_harness( + """ +const costItems = [{name: "ecs"}]; +const payload = {metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + taskId: "task-1", + contextId: "ctx-1", + sequence: 7, + step: {id: "confirm_and_select", status: "working"}, + candidate: {index: 0}, + data: { + detailId: "detail-1", + detail: { + candidateName: "低成本 ECS 方案", + totalMonthlyCost: "CNY 60", + costItems + } + } +}}}}; +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), payload); +costItems[0].name = "mutated"; +payload.metadata.iac_code.pipeline.data.detail.candidateName = "被污染"; +return { + eventName: next.steps.confirm_and_select.events[0].data.detail.candidateName, + eventCostItemName: next.steps.confirm_and_select.events[0].data.detail.costItems[0].name, + candidateName: next.candidates[0].name, + candidateCostItemName: next.candidates[0].costItems[0].name +}; +""" + ) + + assert output == { + "eventName": "低成本 ECS 方案", + "eventCostItemName": "ecs", + "candidateName": "低成本 ECS 方案", + "candidateCostItemName": "ecs", + } + + +def test_reducer_clones_existing_step_events_when_cloning_state() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.steps.confirm_and_select.events.push({ + eventType: "candidate_detail_shown", + data: {detail: {candidateName: "旧事件", costItems: [{name: "ecs"}]}} +}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + step: {id: "architecture_planning", status: "completed"} + }}} +}); +state.steps.confirm_and_select.events[0].data.detail.candidateName = "mutated"; +state.steps.confirm_and_select.events[0].data.detail.costItems[0].name = "mutated"; +return { + sameEvent: next.steps.confirm_and_select.events[0] === state.steps.confirm_and_select.events[0], + eventName: next.steps.confirm_and_select.events[0].data.detail.candidateName, + costItemName: next.steps.confirm_and_select.events[0].data.detail.costItems[0].name +}; +""" + ) + + assert output == { + "sameEvent": False, + "eventName": "旧事件", + "costItemName": "ecs", + } + + +def test_upsert_candidate_deep_clones_nested_payload() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const candidate = { + name: "方案", + candidateIndex: 0, + metadata: {source: {tool: "planner"}}, + costItems: [{name: "ecs", detail: {region: "cn-hangzhou"}}] +}; +const next = reducers.upsertCandidate(state, candidate); +candidate.name = "mutated"; +candidate.metadata.source.tool = "mutated"; +candidate.costItems[0].detail.region = "mutated"; +return { + name: next.candidates[0].name, + tool: next.candidates[0].metadata.source.tool, + region: next.candidates[0].costItems[0].detail.region +}; +""" + ) + + assert output == { + "name": "方案", + "tool": "planner", + "region": "cn-hangzhou", + } + + +def test_reducer_events_only_payload_does_not_duplicate_first_event() -> None: + output = reducer_harness( + """ +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + events: [ + {eventType: "step_completed", sequence: 1, step: {id: "architecture_planning", status: "completed"}}, + {eventType: "step_completed", sequence: 2, step: {id: "evaluate_candidates", status: "completed"}} + ] +}); +return { + architectureEvents: next.steps.architecture_planning.events.length, + evaluateEvents: next.steps.evaluate_candidates.events.length, + lastSequence: next.lastSequence +}; +""" + ) + + assert output == { + "architectureEvents": 1, + "evaluateEvents": 1, + "lastSequence": 2, + } + + +def test_create_initial_state_does_not_alias_defaults_object() -> None: + output = reducer_harness( + """ +const defaults = {serverUrl: "http://server", cwd: "/workspace", nested: {mode: "x"}}; +const state = reducers.createInitialState(defaults); +defaults.serverUrl = "mutated"; +defaults.nested.mode = "mutated"; +return { + serverUrl: state.serverUrl, + defaultsServerUrl: state.defaults.serverUrl, + defaultsMode: state.defaults.nested.mode +}; +""" + ) + + assert output == { + "serverUrl": "http://server", + "defaultsServerUrl": "http://server", + "defaultsMode": "x", + } + + +def test_reducer_clones_existing_defaults_when_cloning_state() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({serverUrl: "http://server", cwd: "/workspace", nested: {mode: "x"}}); +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + step: {id: "architecture_planning", status: "completed"} + }}} +}); +state.defaults.nested.mode = "mutated"; +return { + sameDefaults: next.defaults === state.defaults, + nextMode: next.defaults.nested.mode +}; +""" + ) + + assert output == { + "sameDefaults": False, + "nextMode": "x", + } + + +def test_build_stream_payload_uses_active_task_before_handoff() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({serverUrl: "http://server", cwd: "/workspace"}); +state.contextId = "ctx-1"; +state.pipelineTaskId = "pipeline-task"; +state.activeTaskId = "active-task"; +const beforeHandoff = reducers.buildStreamPayload(state, "部署 nginx"); +state.normalHandoffReady = true; +const afterHandoff = reducers.buildStreamPayload(state, "继续部署"); +return { + beforeHandoff, + afterHandoff +}; +""" + ) + + assert output == { + "beforeHandoff": { + "serverUrl": "http://server", + "cwd": "/workspace", + "contextId": "ctx-1", + "taskId": "active-task", + "prompt": "部署 nginx", + }, + "afterHandoff": { + "serverUrl": "http://server", + "cwd": "/workspace", + "contextId": "ctx-1", + "taskId": "", + "prompt": "继续部署", + }, + } + + +def test_candidate_selection_prompt_uses_zero_based_index() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.candidates = [ + {name: "ECS 经典网络方案", candidateIndex: 0}, + {name: "轻量应用服务器一体化方案", candidateIndex: 1} +]; +const selected = reducers.selectCandidate(state, 1); +return { + sameState: selected === state, + selected: state.selectedCandidateIndex, + prompt: reducers.promptForSelectedCandidate(state), + emptyPrompt: reducers.promptForSelectedCandidate(reducers.createInitialState({})) +}; +""" + ) + + assert output == { + "sameState": True, + "selected": 1, + "prompt": "选择方案1", + "emptyPrompt": "", + } + + +def test_controller_initially_hides_left_steps_and_composer_progress() -> None: + output = controller_harness( + """ +controller.init(); +const leftSteps = all("[data-step-id]"); +const progressSteps = all("[data-progress-step]"); +return { + leftStepCount: leftSteps.length, + progressCount: progressSteps.length, + progressHidden: elementById("composer-progress").hidden, + variant: elementById("composer-progress").getAttribute("data-progress-variant"), + progressText: text(elementById("composer-progress")) +}; +""" + ) + + assert output == { + "leftStepCount": 0, + "progressCount": 0, + "progressHidden": True, + "variant": "b", + "progressText": "", + } + + +def test_controller_reveals_composer_progress_after_pipeline_started() -> None: + output = controller_harness( + """ +controller.init(); +const initialHidden = elementById("composer-progress").hidden; +const next = reducers.reducePipelinePayload(debug.state(), { + metadata: {iac_code: {pipeline: { + eventType: "pipeline_started", + status: "working", + taskId: "task-1" + }}} +}); +Object.assign(debug.state(), next); +debug.render(); +const progressSteps = all("[data-progress-step]"); +return { + initialHidden, + progressHidden: elementById("composer-progress").hidden, + mode: elementById("composer-progress").getAttribute("data-progress-mode"), + progressCount: progressSteps.length, + progressStatuses: progressSteps.map((step) => step.getAttribute("data-status")), + progressText: text(elementById("composer-progress")) +}; +""" + ) + + assert output == { + "initialHidden": True, + "progressHidden": False, + "mode": "pipeline", + "progressCount": 5, + "progressStatuses": ["pending", "pending", "pending", "pending", "pending"], + "progressText": "需求理解架构规划方案评估方案选择确认部署", + } + + +def test_selling_console_chat_column_is_two_thirds_original_width() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + + assert "grid-template-columns: minmax(280px, 400px) minmax(0, 1fr) 56px;" in css + assert "grid-template-columns: minmax(240px, 347px) minmax(0, 1fr);" in css + + +def test_selling_console_removes_left_ai_navigation_rail() -> None: + index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8") + css = STYLES_CSS.read_text(encoding="utf-8") + + assert 'class="ai-rail"' not in index_html + assert "rail-bot" not in index_html + assert "rail-button" not in index_html + assert ".ai-rail" not in css + + +def test_selling_console_left_chat_scrolls_without_moving_composer() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + workflow_rule = css.split(".workflow-panel {", 1)[1].split("}", 1)[0] + step_list_rule = css.split(".step-list {", 1)[1].split("}", 1)[0] + completed_rule = css.split(".step-card.completed {", 1)[1].split("}", 1)[0] + composer_rule = css.split(".composer {", 1)[1].split("}", 1)[0] + + assert "height: calc(100vh - 96px);" in workflow_rule + assert "overflow: hidden;" in workflow_rule + assert "align-content: start;" in step_list_rule + assert "align-items: start;" in step_list_rule + assert "overflow-y: auto;" in step_list_rule + assert "min-height: 0;" in step_list_rule + assert "flex: 1 1 auto;" in step_list_rule + assert "gap: 5px;" in step_list_rule + assert "padding: 8px 14px;" in step_list_rule + assert "grid-template-columns: 24px 1fr;" in completed_rule + assert "padding: 6px 8px;" in completed_rule + assert "flex: 0 0 auto;" in composer_rule + assert "border-top:" not in composer_rule + + +def test_selling_console_chat_messages_have_im_layout_and_avatars() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + + assert ".chat-message.user" in css + assert ".chat-message.system" in css + assert ".chat-avatar.system" in css + assert ".chat-avatar.user" in css + + +def test_selling_console_chat_and_progress_use_compact_spacing() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + chat_message_rule = css.split(".chat-message {", 1)[1].split("}", 1)[0] + step_title_rule = css.split(".step-card h2 {", 1)[1].split("}", 1)[0] + composer_rule = css.split(".composer {", 1)[1].split("}", 1)[0] + composer_progress_rule = css.split(".composer-progress:not([hidden]) {", 1)[1].split("}", 1)[0] + signal_circuit_rule = css.split(".signal-circuit {", 1)[1].split("}", 1)[0] + signal_svg_rule = css.split(".signal-svg {", 1)[1].split("}", 1)[0] + signal_labels_rule = css.split(".signal-labels {", 1)[1].split("}", 1)[0] + + assert "gap: 7px;" in chat_message_rule + assert "font-size: 13px;" in step_title_rule + assert "padding: 6px 14px 10px;" in composer_rule + assert "margin-bottom: 8px;" in composer_progress_rule + assert "padding-bottom: 8px;" in composer_progress_rule + assert "height: 50px;" in signal_circuit_rule + assert "height: 36px;" in signal_svg_rule + assert "top: 32px;" in signal_labels_rule + + +def test_selling_console_step_rows_hide_sequence_numbers_and_use_compact_marker() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + step_index_rule = css.split(".step-index {", 1)[1].split("}", 1)[0] + + assert "step-number" not in APP_JS.read_text(encoding="utf-8") + assert "width: 22px;" in step_index_rule + assert "height: 22px;" in step_index_rule + + +def test_selling_console_left_intro_and_top_alert_are_visually_hidden() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + panel_heading_rule = css.split(".panel-heading {", 1)[1].split("}", 1)[0] + status_alert_rule = css.split(".status-alert {", 1)[1].split("}", 1)[0] + + assert "display: none;" in panel_heading_rule + assert "display: none;" in status_alert_rule + + +def test_selling_console_composer_uses_compact_input_box() -> None: + index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8") + css = STYLES_CSS.read_text(encoding="utf-8") + composer_rule = css.split(".composer {", 1)[1].split("}", 1)[0] + composer_box_rule = css.split(".composer-box {", 1)[1].split("}", 1)[0] + input_rule = css.split("#composer-input {", 1)[1].split("}", 1)[0] + send_button_rule = css.split(".send-icon-button {", 1)[1].split("}", 1)[0] + mobile_compact_rule = css.split("@media (max-width: 560px)", 1)[1].split(".plan-meta", 1)[0] + + assert 'class="composer-box"' in index_html + assert 'rows="2"' in index_html + assert 'placeholder="继续补充您的需求,比如降低成本、提升可用性或约束地域"' in index_html + assert 'aria-label="附件"' in index_html + assert 'aria-label="发送"' in index_html + assert "padding: 6px 14px 10px;" in composer_rule + assert "padding: 10px 10px 9px;" in composer_box_rule + assert "min-height: 40px;" in input_rule + assert "border: 0;" in input_rule + assert "resize: none;" in input_rule + assert "width: 36px;" in send_button_rule + assert "height: 36px;" in send_button_rule + assert ".composer .send-icon-button" in mobile_compact_rule + assert "width: 36px;" in mobile_compact_rule + assert ".composer .icon-only-button" in mobile_compact_rule + assert "width: 32px;" in mobile_compact_rule + + +def test_selling_console_connection_controls_live_in_debug_panel() -> None: + index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8") + plan_header = index_html.split('
', 1)[1].split('
', 1)[1].split( + '
None: + index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8") + diagnostics = index_html.split('
', 1)[1].split("
", 1)[0] + + assert "Pipeline Diagnostics" in diagnostics + assert '
Pipeline Diagnostics" not in index_html + + +def test_selling_console_has_handoff_notice_and_debug_session_info_slots() -> None: + index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8") + + assert 'id="normal-handoff-notice"' not in index_html + assert 'id="debug-session-info"' in index_html + assert 'class="debug-session-info"' in index_html + + +def test_selling_console_candidate_subpipeline_body_is_height_limited() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + + assert ".candidate-subpipeline-body" in css + body_rule = css.split(".candidate-subpipeline-body", 1)[1].split("}", 1)[0] + assert "max-height:" in body_rule + assert "overflow-y: auto;" in body_rule + + +def test_selling_console_running_step_event_list_is_height_limited() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + event_list_rule = css.split(".step-event-list {", 1)[1].split("}", 1)[0] + + assert "max-height:" in event_list_rule + assert "overflow-y: auto;" in event_list_rule + + +def test_selling_console_template_popover_can_be_entered_and_scrolled() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + popover_rule = css.split(".template-popover {", 1)[1].split("}", 1)[0] + popover_hover_rule = css.split(".template-popover:hover {", 1)[1].split("}", 1)[0] + + assert ".template-popover-host:hover .template-popover" in css + assert ".template-popover:hover" in css + assert "max-height:" in popover_rule + assert "overflow-y: auto;" in popover_rule + assert "pointer-events: auto;" in popover_rule + assert "transition-delay: 0ms, 0ms, 140ms;" in popover_rule + assert "transition-delay: 500ms, 500ms, 500ms;" in popover_hover_rule + + +def test_selling_console_plan_grid_keeps_cards_top_aligned_when_process_expands() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + plans_grid_rule = css.split(".plans-grid", 1)[1].split("}", 1)[0] + + assert "align-items: start;" in plans_grid_rule + + +def test_selling_console_composer_progress_uses_separator_instead_of_floating_panel() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + + assert ".composer-progress[hidden]" in css + assert ".composer-progress:not([hidden])" in css + visible_rule = css.split(".composer-progress:not([hidden])", 1)[1].split("}", 1)[0] + assert "border-bottom: 1px solid var(--line);" in visible_rule + assert "border-top:" not in visible_rule + assert "box-shadow:" not in visible_rule + assert "background:" not in visible_rule + + +def test_selling_console_progress_variants_match_unframed_visual_requirements() -> None: + css = STYLES_CSS.read_text(encoding="utf-8") + chevron_root_rule = css.split(".composer-progress.chevrons {", 1)[1].split("}", 1)[0] + chevron_step_rule = css.split(".chevrons .step {", 1)[1].split("}", 1)[0] + signal_rule = css.split(".signal-circuit {", 1)[1].split("}", 1)[0] + + assert "height: 32px;" in chevron_root_rule + assert "font-size: 10px;" in chevron_step_rule + assert "padding: 0 10px 0 14px;" in chevron_step_rule + assert "border:" not in signal_rule + assert "background:" not in signal_rule + + +def test_selling_console_progress_debug_panel_declares_three_adjustable_variants() -> None: + index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8") + assert '
' in index_html + assert '
({ + variant: button.getAttribute("data-progress-variant-option"), + pressed: button.getAttribute("aria-pressed"), + text: text(button) +})); +const paramGroups = all("[data-progress-param-group]").map((group) => ({ + variant: group.getAttribute("data-progress-param-group"), + hidden: group.hidden +})); +const params = all("[data-progress-param]").map((input) => ({ + name: input.getAttribute("data-progress-param"), + variant: input.getAttribute("data-progress-param-variant"), + value: input.value +})); +return { + progressVariant: elementById("composer-progress").getAttribute("data-progress-variant"), + variantButtons, + paramGroups, + params +}; +""" + ) + + assert output["progressVariant"] == "b" + assert output["variantButtons"] == [ + {"variant": "a", "pressed": "false", "text": "A 箭头轨道"}, + {"variant": "b", "pressed": "true", "text": "B 脉冲线路"}, + {"variant": "d", "pressed": "false", "text": "D 输入框融合"}, + ] + assert output["paramGroups"] == [ + {"variant": "a", "hidden": True}, + {"variant": "b", "hidden": False}, + {"variant": "d", "hidden": True}, + ] + assert {"variant": "a", "name": "sweepMs", "value": "1800"} in output["params"] + assert {"variant": "b", "name": "xPercent", "value": "28"} in output["params"] + assert {"variant": "b", "name": "yPercent", "value": "49"} in output["params"] + assert {"variant": "b", "name": "t1", "value": "140"} in output["params"] + assert {"variant": "b", "name": "t2", "value": "540"} in output["params"] + assert {"variant": "b", "name": "maxAmplitude", "value": "9"} in output["params"] + assert {"variant": "b", "name": "pauseTime", "value": "510"} in output["params"] + assert {"variant": "d", "name": "t1", "value": "1800"} in output["params"] + assert {"variant": "d", "name": "t2", "value": "300"} in output["params"] + assert all(item["name"] not in {"shineWidth", "lineWidth", "sweepWidth"} for item in output["params"]) + + +def test_selling_console_progress_debug_panel_switches_variant_and_updates_param() -> None: + output = controller_harness( + """ +controller.init(); +const optionD = all("[data-progress-variant-option]").find((button) => + button.getAttribute("data-progress-variant-option") === "d" +); +optionD.click(); +const afterSwitch = { + progressVariant: elementById("composer-progress").getAttribute("data-progress-variant"), + groups: all("[data-progress-param-group]").map((group) => ({ + variant: group.getAttribute("data-progress-param-group"), + hidden: group.hidden + })) +}; +const dT1 = all("[data-progress-param]").find((input) => + input.getAttribute("data-progress-param-variant") === "d" && + input.getAttribute("data-progress-param") === "t1" +); +dT1.value = "2200"; +(dT1.listeners.input || []).forEach((listener) => listener({type: "input"})); +return { + afterSwitch, + progressVariant: elementById("composer-progress").getAttribute("data-progress-variant"), + stateValue: debug.state().progressUi.d.t1, + renderedValue: all("[data-progress-param]").find((input) => + input.getAttribute("data-progress-param-variant") === "d" && + input.getAttribute("data-progress-param") === "t1" + ).value +}; +""" + ) + + assert output == { + "afterSwitch": { + "progressVariant": "d", + "groups": [ + {"variant": "a", "hidden": True}, + {"variant": "b", "hidden": True}, + {"variant": "d", "hidden": False}, + ], + }, + "progressVariant": "d", + "stateValue": 2200, + "renderedValue": "2200", + } + + +def test_selling_console_debug_step_is_isolated_from_pipeline_progress() -> None: + output = controller_harness( + """ +controller.init(); +const drawer = elementById("debug-drawer"); +const progress = elementById("composer-progress"); +const initial = { + hidden: progress.hidden, + stepCount: all("[data-progress-step]").length +}; +drawer.open = true; +(drawer.listeners.toggle || []).forEach((listener) => listener({type: "toggle"})); +all("[data-progress-step-option]")[3].click(); +const debugOpen = { + hidden: progress.hidden, + mode: progress.getAttribute("data-progress-mode"), + activeIndex: progress.children[0].getAttribute("data-active-index"), + debugStep: debug.state().progressUi.activeStepIndex +}; +drawer.open = false; +(drawer.listeners.toggle || []).forEach((listener) => listener({type: "toggle"})); +const closed = { + hidden: progress.hidden, + stepCount: all("[data-progress-step]").length, + debugStep: debug.state().progressUi.activeStepIndex +}; +const next = reducers.reducePipelinePayload(debug.state(), { + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "intent_parsing"} + }}} +}); +Object.assign(debug.state(), next); +debug.render(); +const pipeline = { + hidden: progress.hidden, + mode: progress.getAttribute("data-progress-mode"), + activeIndex: progress.children[0].getAttribute("data-active-index"), + debugStep: debug.state().progressUi.activeStepIndex +}; +return {initial, debugOpen, closed, pipeline}; +""" + ) + + assert output == { + "initial": {"hidden": True, "stepCount": 0}, + "debugOpen": {"hidden": False, "mode": "debug", "activeIndex": "3", "debugStep": 3}, + "closed": {"hidden": True, "stepCount": 0, "debugStep": 3}, + "pipeline": {"hidden": False, "mode": "pipeline", "activeIndex": "0", "debugStep": 3}, + } + + +def test_selling_console_progress_variants_use_prototype_dom_classes() -> None: + output = controller_harness( + """ +controller.init(); +const drawer = elementById("debug-drawer"); +drawer.open = true; +(drawer.listeners.toggle || []).forEach((listener) => listener({type: "toggle"})); +const progress = elementById("composer-progress"); +const bClass = progress.children[0].getAttribute("class"); +all("[data-progress-variant-option]") + .find((button) => button.getAttribute("data-progress-variant-option") === "a") + .click(); +const aRootClass = progress.getAttribute("class"); +const aStepClasses = progress.children.map((child) => child.getAttribute("class")); +all("[data-progress-variant-option]") + .find((button) => button.getAttribute("data-progress-variant-option") === "d") + .click(); +const dClass = progress.children[0].getAttribute("class"); +return { + bClass, + aRootClass, + aStepClasses, + dClass +}; +""" + ) + + assert output["bClass"] == "signal-circuit" + assert "chevrons" in output["aRootClass"] + assert output["aStepClasses"][0].startswith("step ") + assert output["dClass"] == "fusion-label" + + +def test_selling_console_progress_uses_pipeline_active_step_when_debug_step_is_unset() -> None: + output = controller_harness( + """ +controller.init(); +debug.loadDemoCandidates(); +const progress = elementById("composer-progress"); +return { + activeIndex: progress.children[0].getAttribute("data-active-index"), + uiActiveStepIndex: debug.state().progressUi.activeStepIndex +}; +""" + ) + + assert output == { + "activeIndex": "3", + "uiActiveStepIndex": None, + } + + +def test_controller_reveals_running_step_events_then_collapses_completed_conclusion() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "intent_parsing"}, + data: {summary: "开始理解需求"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "intent_parsing"}, + data: {text: "正在分析地域与预算"} + }}} +}); +const runningText = text(all("[data-step-id]")[0]); +const runningProgress = all("[data-progress-step]").map((step) => ({ + id: step.getAttribute("data-progress-step"), + status: step.getAttribute("data-status") +})); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + step: {id: "intent_parsing"}, + data: { + conclusion: { + scenario: "Nginx 静态站点", + region: "华东 1(杭州)", + budget: "低成本" + } + } + }}} +}); +const completedText = text(all("[data-step-id]")[0]); +const resultFields = all("[data-step-result-field]").map((field) => ({ + field: field.getAttribute("data-step-result-field"), + text: text(field) +})); +const completedEvents = all("[data-step-event-kind]").map((event) => event.getAttribute("data-step-event-kind")); +const stateIcons = all("[data-step-state-icon]").map((icon) => ({ + state: icon.getAttribute("data-step-state-icon"), + text: text(icon) +})); +const toggles = all("[data-step-toggle]"); +toggles[0].click(); +const expandedText = text(all("[data-step-id]")[0]); +const expandedFields = all("[data-step-result-field]").map((field) => text(field)); +toggles[0].click(); +const recollapsedText = text(all("[data-step-id]")[0]); +const completedProgress = all("[data-progress-step]").map((step) => ({ + id: step.getAttribute("data-progress-step"), + status: step.getAttribute("data-status") +})); +return { + stepCount: all("[data-step-id]").length, + runningText, + runningProgress, + completedText, + resultFields, + completedEvents, + stateIcons, + toggleCount: toggles.length, + expandedText, + expandedFields, + recollapsedText, + completedProgress +}; +""" + ) + + assert output["stepCount"] == 1 + assert "需求理解" in output["runningText"] + assert "思考中" in output["runningText"] + assert "开始理解需求" in output["runningText"] + assert "正在分析地域与预算" in output["runningText"] + assert {"id": "intent_parsing", "status": "working"} in output["runningProgress"] + assert output["completedText"] == "✓需求理解" + assert "思考完成" not in output["completedText"] + assert "场景:Nginx 静态站点" not in output["completedText"] + assert "地域:华东 1(杭州)" not in output["completedText"] + assert "预算:低成本" not in output["completedText"] + assert ";" not in output["completedText"] + assert output["resultFields"] == [] + assert output["completedEvents"] == [] + assert output["stateIcons"] == [{"state": "completed", "text": "✓"}] + assert output["toggleCount"] == 1 + assert "场景:Nginx 静态站点" in output["expandedText"] + assert "地域:华东 1(杭州)" in output["expandedText"] + assert "预算:低成本" in output["expandedText"] + assert output["expandedFields"] == ["场景:Nginx 静态站点", "地域:华东 1(杭州)", "预算:低成本"] + assert "场景:Nginx 静态站点" not in output["recollapsedText"] + assert "正在分析地域与预算" not in output["completedText"] + assert {"id": "intent_parsing", "status": "completed"} in output["completedProgress"] + + +def test_controller_renders_chat_messages_with_user_and_system_avatars() -> None: + output = controller_harness( + """ +controller.init(); +debug.state().userMessages = [{id: "u-1", text: "创建一个 VPC"}]; +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "intent_parsing"}, + data: {summary: "开始理解需求"} + }}} +}); +return { + messages: all("[data-chat-message]").map((item) => ({ + role: item.getAttribute("data-chat-message"), + text: text(item) + })), + avatars: all("[data-chat-avatar]").map((item) => ({ + role: item.getAttribute("data-chat-avatar"), + text: text(item) + })) +}; +""" + ) + + assert output["messages"][0] == {"role": "user", "text": "U创建一个 VPC"} + assert output["messages"][1]["role"] == "system" + assert output["messages"][1]["text"].startswith("AI") + assert "需求理解思考中" in output["messages"][1]["text"] + assert output["avatars"][:2] == [{"role": "user", "text": "U"}, {"role": "system", "text": "AI"}] + + +def test_controller_places_user_messages_after_related_pipeline_context() -> None: + output = controller_harness( + """ +controller.init(); +global.fetch = async () => ({ + ok: true, + status: 200, + body: null, + text: async () => "" +}); + +elementById("composer-input").value = "选择一个已有vpc,创建一个vswitch"; +await controller.sendComposerMessage(); + +const state = debug.state(); +state.pipelineStarted = true; +state.steps.intent_parsing.status = "completed"; +state.steps.architecture_planning.status = "completed"; +state.steps.evaluate_candidates.status = "completed"; +state.steps.confirm_and_select.status = "waiting_input"; +state.status = "waiting_input"; +state.pendingInput = { + kind: "candidate_selection", + prompt: "请选择要部署的方案:", + options: [{id: "0", label: "方案0"}] +}; +debug.render(); + +elementById("composer-input").value = "选择方案0"; +await controller.sendComposerMessage(); + +state.steps.confirm_and_select.status = "completed"; +state.steps.deploying.status = "completed"; +state.pendingInput = null; +state.normalHandoffReady = true; +state.status = "completed"; +debug.render(); + +elementById("composer-input").value = "刚才创建了什么?"; +await controller.sendComposerMessage(); + +const messages = all("[data-chat-message]").map((item) => ({ + role: item.getAttribute("data-chat-message"), + text: text(item) +})); +const indexOf = (needle) => messages.findIndex((item) => item.text.includes(needle)); +return { + messages, + firstUser: indexOf("选择一个已有vpc"), + selectStep: indexOf("方案选择"), + secondUser: indexOf("选择方案0"), + handoff: indexOf("部署流程已完成"), + thirdUser: indexOf("刚才创建了什么") +}; +""" + ) + + assert output["firstUser"] >= 0 + assert output["selectStep"] >= 0 + assert output["secondUser"] > output["selectStep"] + assert output["handoff"] >= 0 + assert output["thirdUser"] > output["handoff"] + + +def test_controller_clears_composer_as_soon_as_message_is_submitted() -> None: + output = controller_harness( + """ +controller.init(); +let valueSeenByFetch = null; +global.fetch = async () => { + valueSeenByFetch = elementById("composer-input").value; + return { + ok: true, + status: 200, + body: null, + text: async () => "" + }; +}; +elementById("composer-input").value = "创建一个 VPC"; +await controller.sendComposerMessage(); +return { + valueSeenByFetch, + finalValue: elementById("composer-input").value, + messages: all("[data-chat-message]").map((item) => text(item)) +}; +""" + ) + + assert output["valueSeenByFetch"] == "" + assert output["finalValue"] == "" + assert any("创建一个 VPC" in item for item in output["messages"]) + + +def test_controller_scrolls_left_chat_to_bottom_after_step_updates() -> None: + output = controller_harness( + """ +controller.init(); +const stepList = elementById("step-list"); +stepList.scrollTop = 0; +stepList.scrollHeight = 240; +stepList.clientHeight = 60; +const next = reducers.reducePipelinePayload(debug.state(), { + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "intent_parsing"}, + data: {text: "正在持续分析用户需求,内容已经超过可视区域"} + }}} +}); +Object.assign(debug.state(), next); +debug.render(); +return { + scrollTop: stepList.scrollTop, + scrollHeight: stepList.scrollHeight +}; +""" + ) + + assert output == {"scrollTop": 240, "scrollHeight": 240} + + +def test_controller_scrolls_active_step_event_list_to_bottom_after_updates() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(text) { + const next = reducers.reducePipelinePayload(debug.state(), { + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "intent_parsing"}, + data: {text} + }}} + }); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload("第一段分析"); +applyPayload("第二段分析"); +const eventList = all("[data-step-event-list]")[0]; +return { + exists: Boolean(eventList), + scrollTop: eventList && eventList.scrollTop, + scrollHeight: eventList && eventList.scrollHeight +}; +""" + ) + + assert output == {"exists": True, "scrollTop": 100, "scrollHeight": 100} + + +def test_controller_renders_normal_chat_answer_with_collapsible_process_after_handoff() -> None: + output = controller_harness( + """ +controller.init(); +global.fetch = async () => ({ + ok: true, + status: 200, + body: null, + text: async () => [ + { + taskId: "normal-task", + contextId: "ctx-1", + status: {state: "TASK_STATE_WORKING"}, + metadata: {iac_code: {thinking: {type: "raw_thinking", text: "读取刚才部署结果"}}} + }, + { + taskId: "normal-task", + contextId: "ctx-1", + status: {state: "TASK_STATE_WORKING"}, + metadata: { + iac_code: { + tool: { + status: "completed", + toolUseId: "toolu-read", + name: "read_file", + result: {content: "读取部署摘要"} + } + } + } + }, + { + taskId: "normal-task", + contextId: "ctx-1", + status: {state: "TASK_STATE_WORKING", message: {parts: [{text: "刚才创建了一个 VSwitch。"}]}} + }, + { + taskId: "normal-task", + contextId: "ctx-1", + status: {state: "TASK_STATE_INPUT_REQUIRED"} + } + ].map((item) => `data: ${JSON.stringify(item)}`).join("\\n\\n") +}); +Object.assign(debug.state(), { + contextId: "ctx-1", + normalHandoffReady: true, + status: "completed" +}); +debug.render(); +elementById("composer-input").value = "刚才创建了什么?"; +await controller.sendComposerMessage(); + +const messages = all("[data-chat-message]").map((item) => ({ + role: item.getAttribute("data-chat-message"), + text: text(item) +})); +const turns = all("[data-normal-turn]").map((item) => ({ + id: item.getAttribute("data-normal-turn"), + text: text(item) +})); +const process = all("[data-normal-process]")[0]; +const events = all("[data-normal-process-event]").map((item) => ({ + kind: item.getAttribute("data-normal-process-event"), + text: text(item) +})); +return { + messages, + turns, + processOpen: process && process.open === true, + processText: process && text(process), + events, + answer: text(all("[data-normal-answer]")[0]), + normalStatus: debug.state().normalTurns[0] && debug.state().normalTurns[0].status +}; +""" + ) + + assert any(item["role"] == "user" and "刚才创建了什么?" in item["text"] for item in output["messages"]) + assert len(output["turns"]) == 1 + assert output["normalStatus"] == "completed" + assert output["processOpen"] is False + assert output["events"] == [ + {"kind": "thinking", "text": "思考读取刚才部署结果"}, + {"kind": "tool", "text": "工具read_file 完成 读取部署摘要"}, + ] + assert output["processText"].startswith("思考过程") + assert output["answer"] == "刚才创建了一个 VSwitch。" + + +def test_controller_renders_normal_chat_answer_from_task_history_after_handoff() -> None: + output = controller_harness( + """ +controller.init(); +global.fetch = async () => ({ + ok: true, + status: 200, + body: null, + text: async () => [ + { + jsonrpc: "2.0", + result: { + id: "normal-task", + contextId: "ctx-1", + status: {state: "TASK_STATE_INPUT_REQUIRED"}, + history: [ + { + role: "user", + parts: [{root: {kind: "text", text: "你刚才创建了啥"}}] + }, + { + role: "agent", + parts: [ + {root: {kind: "text", text: "刚才在已有 VPC 中新建了一个 VSwitch。"}}, + {root: {kind: "text", text: "VSwitch ID 是 vsw-123。"}} + ] + } + ] + } + } + ].map((item) => `data: ${JSON.stringify(item)}`).join("\\n\\n") +}); +Object.assign(debug.state(), { + contextId: "ctx-1", + normalHandoffReady: true, + status: "completed" +}); +debug.render(); +elementById("composer-input").value = "你刚才创建了啥"; +await controller.sendComposerMessage(); + +return { + turns: all("[data-normal-turn]").length, + answer: text(all("[data-normal-answer]")[0]), + normalStatus: debug.state().normalTurns[0] && debug.state().normalTurns[0].status +}; +""" + ) + + assert output == { + "turns": 1, + "answer": "刚才在已有 VPC 中新建了一个 VSwitch。VSwitch ID 是 vsw-123。", + "normalStatus": "completed", + } + + +def test_controller_expanded_step_shows_all_conclusion_fields() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + step: {id: "intent_parsing"}, + data: { + conclusion: { + core_requirements: "VPC", + cloud_platform: "aliyun", + user_message_summary: "创建一个 VPC", + non_functional: "低成本", + additional_notes: "使用默认 CIDR", + business_type: "网络基础设施", + region_preference: "cn-hangzhou", + risk: "后续需补充交换机" + } + } + }}} +}); +all("[data-step-toggle]")[0].click(); +return { + fields: all("[data-step-result-field]").map((field) => ({ + key: field.getAttribute("data-step-result-field"), + text: text(field) + })) +}; +""" + ) + + assert [field["key"] for field in output["fields"]] == [ + "core_requirements", + "cloud_platform", + "user_message_summary", + "non_functional", + "additional_notes", + "business_type", + "region_preference", + "risk", + ] + assert "后续需补充交换机" in output["fields"][-1]["text"] + + +def test_controller_merges_contiguous_text_delta_events_into_typing_card() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "intent_parsing"}, + data: {summary: "开始理解需求"} + }}} +}); +["正在分析", "地域、预算", "和部署约束"].forEach((fragment) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "intent_parsing"}, + data: {text: fragment} + }}} +})); +const cards = all("[data-step-event-kind]").map((item) => ({ + kind: item.getAttribute("data-step-event-kind"), + text: text(item) +})); +return {cards}; +""" + ) + + assert [card["kind"] for card in output["cards"]] == ["step_started", "text_delta"] + assert "思考片段" in output["cards"][1]["text"] + assert "正在分析地域、预算和部署约束" in output["cards"][1]["text"] + + +def test_controller_shows_distinct_icons_for_running_completed_and_waiting_steps() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + step: {id: "intent_parsing"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "architecture_planning"}, + data: {summary: "规划网络拓扑"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + data: {kind: "ask_user_question", prompt: "请选择方案"} + }}} +}); +return { + icons: all("[data-step-state-icon]").map((icon) => ({ + state: icon.getAttribute("data-step-state-icon"), + text: text(icon) + })) +}; +""" + ) + + assert output["icons"] == [ + {"state": "completed", "text": "✓"}, + {"state": "working", "text": "…"}, + {"state": "waiting_input", "text": "?"}, + ] + + +def test_controller_renders_tool_events_as_structured_step_event_cards() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "deploying"}, + data: {summary: "准备部署"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "tool_result", + status: "working", + step: {id: "deploying"}, + data: { + toolName: "CreateStack", + toolUseId: "tool-1", + result: { + stackId: "stack-123", + stackStatus: "CREATE_COMPLETE" + } + } + }}} +}); +const eventCards = all("[data-step-event-kind]").map((item) => ({ + kind: item.getAttribute("data-step-event-kind"), + text: text(item) +})); +return { + count: eventCards.length, + eventCards +}; +""" + ) + + assert output["count"] == 2 + assert output["eventCards"][1]["kind"] == "tool_result" + assert "工具结果" in output["eventCards"][1]["text"] + assert "CreateStack" in output["eventCards"][1]["text"] + assert "Tool Use" not in output["eventCards"][1]["text"] + assert "tool-1" not in output["eventCards"][1]["text"] + assert "stack-123" in output["eventCards"][1]["text"] + assert "CREATE_COMPLETE" in output["eventCards"][1]["text"] + + +def test_controller_renders_candidate_subpipeline_below_matching_plan_card() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +[ + {index: 0, name: "标准 VPC 网络"}, + {index: 1, name: "VPC 含可用区交换机"} +].forEach((candidate) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: candidate.index}, + data: {detail: {candidateName: candidate.name, candidateIndex: candidate.index}} + }}} +})); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "cost_estimation", label: "成本估算", status: "working"}, + data: {summary: "开始估算成本"} + }}} +}); +["查询规格", "与价格"].forEach((fragment) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "cost_estimation", label: "成本估算", status: "working"}, + data: {text: fragment} + }}} +})); +const pipelines = all("[data-candidate-subpipeline]").map((item) => ({ + candidate: item.getAttribute("data-candidate-subpipeline"), + open: item.open === true, + text: text(item) +})); +const events = all("[data-candidate-subpipeline-event]").map((item) => ({ + kind: item.getAttribute("data-candidate-subpipeline-event"), + text: text(item) +})); +return {pipelines, events}; +""" + ) + + assert len(output["pipelines"]) == 1 + assert output["pipelines"][0]["candidate"] == "0" + assert output["pipelines"][0]["open"] is True + assert "思考过程" in output["pipelines"][0]["text"] + assert "成本估算" in output["pipelines"][0]["text"] + assert "开始估算成本" in output["pipelines"][0]["text"] + assert [event["kind"] for event in output["events"]] == ["candidate_step_started", "text_delta"] + assert "查询规格与价格" in output["events"][1]["text"] + + +def test_controller_auto_collapses_completed_candidate_subpipeline_on_plan_card() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: {detail: {candidateName: "标准 VPC", candidateIndex: 0, summary: "基础网络", totalMonthlyCost: "¥0/月"}} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "cost_estimation", label: "成本估算", status: "working"}, + data: {summary: "开始估算成本"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_completed", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "cost_estimation", label: "成本估算", status: "completed"}, + data: {summary: "成本估算完成"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "completed", + step: {id: "evaluate_candidates"}, + data: {conclusion: {summary: "方案评估完成"}} + }}} +}); +const pipeline = all("[data-candidate-subpipeline]")[0]; +const eventKinds = all("[data-candidate-subpipeline-event]") + .map((item) => item.getAttribute("data-candidate-subpipeline-event")); +return { + open: pipeline.open === true, + text: text(pipeline), + eventKinds +}; +""" + ) + + assert output["open"] is False + assert "思考过程" in output["text"] + assert "思考完成" not in output["text"] + assert output["eventKinds"] == ["candidate_step_started", "candidate_step_completed"] + + +def test_controller_updates_plan_card_and_collapses_subpipeline_when_candidate_completes() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "completed", + step: {id: "architecture_planning"}, + data: { + conclusion: { + draft_candidates: [{ + candidate_index: 0, + candidate_name: "基础 VPC 网络", + first_version_description: "创建一个基础 VPC,作为后续云资源的网络容器。", + rough_monthly_estimate: "待估算" + }] + } + } + }}} +}); +const before = text(all("[data-candidate-index]")[0]); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "cost_estimation", label: "成本估算", status: "working"}, + data: {summary: "开始估算成本"} + }}} +}); +let pipeline = all("[data-candidate-subpipeline]")[0]; +const openWhileWorking = pipeline.open === true; +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_completed", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: { + candidate_name: "基础 VPC 网络", + summary: "VPC 本身免费,适合作为后续子网和云资源的基础容器。", + total_monthly_cost: "¥0/月" + } + }}} +}); +pipeline = all("[data-candidate-subpipeline]")[0]; +return { + before, + after: text(all("[data-candidate-index]")[0]), + openWhileWorking, + openAfterCandidateDone: pipeline.open === true, + substepTexts: all("[data-candidate-substep]").map((item) => text(item)), + subEventKinds: all("[data-candidate-subpipeline-event]") + .map((item) => item.getAttribute("data-candidate-subpipeline-event")) +}; +""" + ) + + assert "创建一个基础 VPC" in output["before"] + assert "待估算" in output["before"] + assert output["openWhileWorking"] is True + assert "VPC 本身免费" in output["after"] + assert "¥0/月" in output["after"] + assert output["openAfterCandidateDone"] is False + assert not any("方案思考" in item for item in output["substepTexts"]) + assert output["subEventKinds"] == ["candidate_step_started"] + + +def test_controller_plan_card_marks_candidate_working_then_completed() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "completed", + step: {id: "architecture_planning"}, + data: { + conclusion: { + candidates: [{ + name: "经济型演示方案", + candidate_index: 0, + topology: "VPC 内单可用区部署一台突发性能 ECS。", + monthly_estimate: "¥50 - ¥80" + }] + } + } + }}} +}); +const initialCardText = text(all("[data-candidate-index]")[0]); +const initialPriceCount = (initialCardText.match(/¥50 - ¥80/g) || []).length; +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0, name: "经济型演示方案"}, + candidateStep: {id: "template_generating"} + }}} +}); +const workingStatus = all("[data-candidate-status]")[0]; +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_completed", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0, name: "经济型演示方案"}, + data: { + candidateIndex: 0, + candidateName: "经济型演示方案", + conclusions: { + template: {description: "经济型 Nginx 演示环境 - VPC 内单可用区部署一台 ECS。"}, + cost: {monthly_estimate: "¥74/月"} + } + } + }}} +}); +const completedStatus = all("[data-candidate-status]")[0]; +return { + initialCardText, + initialPriceCount, + workingStatus: { + value: workingStatus.getAttribute("data-candidate-status"), + text: text(workingStatus) + }, + completedStatus: { + value: completedStatus.getAttribute("data-candidate-status"), + text: text(completedStatus) + }, + completedCardText: text(all("[data-candidate-index]")[0]) +}; +""" + ) + + assert "预估价格" in output["initialCardText"] + assert output["initialPriceCount"] == 1 + assert output["workingStatus"] == {"value": "working", "text": "生成中"} + assert output["completedStatus"] == {"value": "completed", "text": "已完成"} + assert "经济型 Nginx 演示环境" in output["completedCardText"] + assert "¥74/月" in output["completedCardText"] + + +def test_controller_groups_candidate_subpipeline_into_expandable_substeps() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: {detail: {candidateName: "基础 VPC 网络", candidateIndex: 0, summary: "基础网络", totalMonthlyCost: "¥0/月"}} + }}} +}); +[ + { + eventType: "candidate_step_started", + id: "template_generating", + label: "模板生成", + status: "working", + summary: "开始生成模板" + }, + { + eventType: "tool_result", + id: "template_generating", + label: "模板生成", + status: "working", + summary: "写入模板", + toolName: "write_file" + }, + { + eventType: "candidate_step_completed", + id: "template_generating", + label: "模板生成", + status: "completed", + summary: "模板生成完成" + }, + { + eventType: "candidate_step_started", + id: "cost_estimating", + label: "成本估算", + status: "working", + summary: "开始估算成本" + } +].forEach((item) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: item.eventType, + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: item.id, label: item.label, status: item.status}, + data: {summary: item.summary, toolName: item.toolName} + }}} +})); +const pipeline = all("[data-candidate-subpipeline]")[0]; +const substeps = all("[data-candidate-substep]").map((item) => ({ + id: item.getAttribute("data-candidate-substep"), + open: item.open === true, + text: text(item) +})); +const events = all("[data-candidate-subpipeline-event]") + .map((item) => item.getAttribute("data-candidate-subpipeline-event")); +return { + pipelineOpen: pipeline.open === true, + pipelineClickListeners: (pipeline.listeners.click || []).length, + substeps, + events +}; +""" + ) + + assert output["pipelineOpen"] is True + assert output["pipelineClickListeners"] >= 1 + assert [item["id"] for item in output["substeps"]] == ["template_generating", "cost_estimating"] + assert "模板生成" in output["substeps"][0]["text"] + assert "成本估算" in output["substeps"][1]["text"] + assert output["substeps"][1]["open"] is True + assert output["events"] == [ + "candidate_step_started", + "tool_result", + "candidate_step_completed", + "candidate_step_started", + ] + + +def test_controller_marks_candidate_substeps_complete_after_evaluation_step_completes() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: {detail: {candidateName: "基础 VPC 网络", candidateIndex: 0, summary: "基础网络", totalMonthlyCost: "¥0/月"}} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "template_generating", status: "working"}, + data: {summary: "开始生成模板"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "template_generating", status: "working"}, + data: {text: "模板内容已生成"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + step: {id: "evaluate_candidates"}, + data: {conclusion: {summary: "方案评估完成"}} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + data: {kind: "candidate_selection", prompt: "请选择购买方案", options: [{id: "0", label: "基础 VPC 网络"}]} + }}} +}); +const substeps = all("[data-candidate-substep]").map((item) => ({ + id: item.getAttribute("data-candidate-substep"), + open: item.open === true, + text: text(item) +})); +return {substeps}; +""" + ) + + assert output["substeps"] == [ + { + "id": "template_generating", + "open": False, + "text": "模板生成完成子步骤开始开始生成模板思考片段模板内容已生成", + } + ] + + +def test_controller_preserves_open_candidate_subpipeline_when_events_update() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: {detail: {candidateName: "基础 VPC 网络", candidateIndex: 0, summary: "基础网络"}} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "template_generating", status: "working"}, + data: {summary: "开始生成模板"} + }}} +}); +let pipeline = all("[data-candidate-subpipeline]")[0]; +pipeline.open = true; +(pipeline.listeners.toggle || []).forEach((listener) => listener({type: "toggle"})); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "template_generating", status: "working"}, + data: {text: "继续生成"} + }}} +}); +pipeline = all("[data-candidate-subpipeline]")[0]; +return { + openAfterUpdate: pipeline.open === true, + stored: debug.state().expandedCandidateSubpipelines["0"] === true +}; +""" + ) + + assert output == {"openAfterUpdate": True, "stored": True} + + +def test_controller_auto_opens_and_scrolls_candidate_subpipeline_while_evaluating() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "evaluate_candidates"}, + data: {summary: "开始评估方案"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: {detail: {candidateName: "基础 VPC 网络", candidateIndex: 0, summary: "基础网络"}} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "template_generating", status: "working"}, + data: {summary: "开始生成模板"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "template_generating", status: "working"}, + data: {text: "继续生成"} + }}} +}); +const pipeline = all("[data-candidate-subpipeline]")[0]; +const body = all("[data-candidate-subpipeline-body]")[0]; +return { + open: pipeline.open === true, + scrollTop: body && body.scrollTop, + scrollHeight: body && body.scrollHeight +}; +""" + ) + + assert output == {"open": True, "scrollTop": 100, "scrollHeight": 100} + + +def test_controller_candidate_subpipeline_keeps_all_chinese_substeps_and_auto_opens_body() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: {detail: {candidateName: "基础 VPC 网络", candidateIndex: 0, summary: "基础网络", totalMonthlyCost: "¥0/月"}} + }}} +}); +[ + "template_generating", + "cost_estimating", + "quality_review" +].forEach((stepId, stepIndex) => { + for (let index = 0; index < 9; index += 1) { + const isLast = index === 8; + applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: isLast ? "candidate_step_completed" : index === 0 ? "candidate_step_started" : "text_delta", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: stepId, status: isLast ? "completed" : "working"}, + data: {text: `片段 ${stepIndex}-${index}`, summary: `子步骤 ${stepIndex}-${index}`} + }}} + }); + } +}); +const pipeline = all("[data-candidate-subpipeline]")[0]; +const toggle = all("[data-candidate-subpipeline-toggle]")[0]; +const substeps = all("[data-candidate-substep]").map((item) => text(item)); +return { + pipelineOpen: pipeline.open === true, + pipelineText: text(pipeline), + toggleText: text(toggle), + substeps +}; +""" + ) + + assert output["pipelineOpen"] is True + assert output["toggleText"] == "思考过程" + assert "思考完成" not in output["pipelineText"] + assert any("模板生成" in item for item in output["substeps"]) + assert any("成本估算" in item for item in output["substeps"]) + assert any("质量复核" in item for item in output["substeps"]) + assert not any("template_generating" in item or "cost_estimating" in item for item in output["substeps"]) + + +def test_controller_collapses_step_three_completion_in_left_chat_without_duplicate_option_details() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "evaluate_candidates"}, + data: {summary: "开始评估候选方案"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + step: {id: "evaluate_candidates"}, + data: { + conclusion: { + options: [ + { + title: "标准 VPC 网络", + candidateIndex: 0, + summary: "成本较低,扩展性一般", + totalMonthlyCost: "¥33.89/月" + }, + { + title: "VPC 含可用区交换机", + candidateIndex: 1, + summary: "自动创建交换机,部署更顺滑", + totalMonthlyCost: "¥60/月" + } + ] + } + } + }}} +}); +const stepText = text(all("[data-step-id]")[0]); +const resultOptions = all("[data-step-result-option]").map((item) => ({ + option: item.getAttribute("data-step-result-option"), + text: text(item) +})); +return {stepText, resultOptions}; +""" + ) + + assert ";" not in output["stepText"] + assert "已生成 2 个方案" not in output["stepText"] + assert "成本较低,扩展性一般" not in output["stepText"] + assert "自动创建交换机,部署更顺滑" not in output["stepText"] + assert output["resultOptions"] == [] + + +def test_controller_renders_step_three_nested_candidate_conclusion_without_flat_object_text() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + step: {id: "evaluate_candidates"}, + data: { + conclusion: { + 0: { + template: "基础 VPC 网络模板,创建 CIDR 192.168.0.0/16 的专有网络", + cost: { + template_fixed: false, + monthly_estimate: "¥0/月", + currency: "CNY", + api_raw_summary: "GetTemplateEstimateCost 返回 Resources: {},VPC 为免费资源" + }, + candidate: { + name: "基础 VPC 网络", + output_path: "templates/1-basic-vpc.yml", + pros: "满足基础网络隔离需求、零成本、可按需扩展子网和安全组", + monthly_estimate: 0, + cons: "仅含 VPC,需后续手动添加 VSwitch" + } + } + } + } + }}} +}); +all("[data-step-toggle]")[0].click(); +const stepText = text(all("[data-step-id]")[0]); +const resultOptions = all("[data-step-result-option]").map((item) => text(item)); +const candidateResults = all("[data-step-candidate-result]").map((item) => text(item)); +const resultFields = all("[data-step-result-field]").map((field) => text(field)); +return {stepText, resultOptions, candidateResults, resultFields}; +""" + ) + + assert "cost:" not in output["stepText"] + assert "candidate:" not in output["stepText"] + assert "template fixed" not in output["stepText"] + assert ";" not in output["stepText"] + assert output["resultFields"] == [] + assert output["resultOptions"] == [] + assert len(output["candidateResults"]) == 1 + assert "基础 VPC 网络" in output["candidateResults"][0] + assert "基础 VPC 网络模板" in output["candidateResults"][0] + assert "¥0/月" in output["candidateResults"][0] + + +def test_controller_compacts_long_template_text_in_step_three_candidate_result() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +const fullTemplate = [ + "ROSTemplateFormatVersion: '2015-09-01'", + "Description:", + " zh-cn: 经济型突发实例方案,使用 Nginx 托管静态网站", + "Resources:", + " WebServer:", + " Type: ALIYUN::ECS::Instance", + " Properties:", + " InstanceType: ecs.t6-c1m1.large", + " SystemDiskCategory: cloud_essd", + " InternetMaxBandwidthOut: 1" +].join("\\n"); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + data: { + detail: { + candidateName: "经济型突发实例方案", + candidateIndex: 0, + template: fullTemplate, + totalMonthlyCost: "¥24.51/月" + } + } + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "completed", + step: {id: "evaluate_candidates"}, + data: {conclusion: {summary: "方案评估完成"}} + }}} +}); +all("[data-step-toggle]")[0].click(); +const result = all("[data-step-candidate-result]")[0]; +const planCard = all("[data-candidate-index]")[0]; +const summary = all("[data-step-candidate-result-summary]")[0] || null; +const popovers = all("[data-template-popover]").map((popover) => text(popover)); +return { + resultText: text(result), + resultTitle: result ? result.getAttribute("title") : null, + planTitle: planCard ? planCard.getAttribute("title") : null, + summaryText: summary ? text(summary) : "", + summaryTitle: summary ? summary.getAttribute("title") : null, + popovers +}; +""" + ) + + assert "经济型突发实例方案" in output["resultText"] + assert "¥24.51/月" in output["resultText"] + assert "ROSTemplateFormatVersion" not in output["summaryText"] + assert "Resources:" not in output["summaryText"] + assert "模板内容已生成" in output["summaryText"] + assert output["resultTitle"] is None + assert output["planTitle"] is None + assert output["summaryTitle"] is None + assert len(output["popovers"]) == 2 + assert all("ROSTemplateFormatVersion" in item for item in output["popovers"]) + assert all("Resources:" in item for item in output["popovers"]) + + +def test_controller_step_three_expansion_groups_summary_and_process_by_candidate() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +[ + {index: 0, name: "基础 VPC 网络", summary: "VPC 本身免费,适合先建立网络容器", price: "¥0/月"}, + {index: 1, name: "VPC 含交换机", summary: "自动创建交换机,后续部署更顺滑", price: "¥0/月"} +].forEach((candidate) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: candidate.index}, + data: { + detail: { + candidateName: candidate.name, + candidateIndex: candidate.index, + summary: candidate.summary, + totalMonthlyCost: candidate.price + } + } + }}} +})); +[ + {candidateIndex: 0, stepId: "template_generating", text: "生成 VPC 模板"}, + {candidateIndex: 0, stepId: "cost_estimating", text: "确认 VPC 免费"}, + {candidateIndex: 1, stepId: "template_generating", text: "生成 VPC 与交换机模板"}, + {candidateIndex: 1, stepId: "cost_estimating", text: "确认网络资源免费"} +].forEach((item) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: item.candidateIndex}, + candidateStep: {id: item.stepId, status: "working"}, + data: {text: item.text} + }}} +})); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "working", + step: {id: "evaluate_candidates"}, + data: {conclusion: { + 0: {candidate: {name: "基础 VPC 网络"}, summary: "VPC 本身免费,适合先建立网络容器"}, + 1: {candidate: {name: "VPC 含交换机"}, summary: "自动创建交换机,后续部署更顺滑"} + }} + }}} +}); +all("[data-step-toggle]")[0].click(); +const results = all("[data-step-candidate-result]").map((item) => ({ + candidate: item.getAttribute("data-step-candidate-result"), + text: text(item) +})); +const processes = all("[data-step-candidate-result-process]").map((item) => ({ + candidate: item.getAttribute("data-step-candidate-result-process"), + open: item.open === true, + text: text(item) +})); +return {results, processes}; +""" + ) + + assert [item["candidate"] for item in output["results"]] == ["0", "1"] + assert "基础 VPC 网络" in output["results"][0]["text"] + assert "VPC 本身免费" in output["results"][0]["text"] + assert "VPC 含交换机" in output["results"][1]["text"] + assert "自动创建交换机" in output["results"][1]["text"] + assert [item["candidate"] for item in output["processes"]] == ["0", "1"] + assert output["processes"][0]["open"] is False + assert "模板生成" in output["processes"][0]["text"] + assert "成本估算" in output["processes"][0]["text"] + assert "生成 VPC 模板" in output["processes"][0]["text"] + assert "生成 VPC 与交换机模板" in output["processes"][1]["text"] + + +def test_controller_completed_step_expansion_includes_collapsible_process() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +[ + {eventType: "step_started", status: "working", data: {summary: "开始理解需求"}}, + {eventType: "text_delta", status: "working", data: {text: "识别 VPC"}}, + {eventType: "tool_result", status: "working", data: {toolName: "read_context", result: {status: "success"}}}, + {eventType: "step_completed", status: "working", data: {conclusion: { + core_requirements: "VPC", + cloud_platform: "aliyun", + user_message_summary: "创建一个 VPC" + }}} +].forEach((event) => applyPayload({ + metadata: {iac_code: {pipeline: { + ...event, + step: {id: "intent_parsing"} + }}} +})); +all("[data-step-toggle]")[0].click(); +const step = all("[data-step-id]")[0]; +const process = all("[data-step-process]")[0]; +const processEvents = all("[data-step-process-event]").map((item) => ({ + kind: item.getAttribute("data-step-process-event"), + text: text(item) +})); +return { + stepText: text(step), + processOpen: process.open === true, + processText: text(process), + processEvents +}; +""" + ) + + assert "VPC" in output["stepText"] + assert "思考过程" in output["processText"] + assert output["processOpen"] is False + assert [event["kind"] for event in output["processEvents"]] == [ + "step_started", + "text_delta", + "tool_result", + "step_completed", + ] + assert "识别 VPC" in output["processEvents"][1]["text"] + + +def test_controller_summarizes_step_three_left_chat_by_candidate_latest_progress() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +[ + { + index: 0, + name: "基础 VPC 网络", + summary: "使用 192.168.0.0/16 网段,作为后续网络资源的基础容器。" + }, + { + index: 1, + name: "VPC 含可用区交换机", + summary: "创建 VPC 和可用区交换机,后续可直接部署 ECS。" + } +].forEach((candidate) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: candidate.index}, + data: {detail: { + candidateName: candidate.name, + candidateIndex: candidate.index, + summary: candidate.summary, + totalMonthlyCost: "¥0/月" + }} + }}} +})); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "cost_estimating", label: "成本估算"}, + data: {summary: "开始估算成本"} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "tool_result", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 0}, + candidateStep: {id: "cost_estimating", label: "成本估算"}, + data: {toolName: "GetTemplateEstimateCost", result: {status: "success"}} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_step_started", + status: "working", + step: {id: "evaluate_candidates"}, + candidate: {index: 1}, + candidateStep: {id: "template_validating", label: "模板校验"}, + data: {summary: "校验模板参数"} + }}} +}); +const stepText = text(all("[data-step-id]")[0]); +const heads = all("[data-step-candidate-progress-head]").map((item) => text(item)); +const summaries = all("[data-step-candidate-progress]").map((item) => ({ + index: item.getAttribute("data-step-candidate-progress"), + text: text(item) +})); +return {stepText, heads, summaries}; +""" + ) + + assert output["heads"] == ["方案 0基础 VPC 网络", "方案 1VPC 含可用区交换机"] + assert len(output["summaries"]) == 2 + assert output["summaries"] == [ + {"index": "0", "text": "方案 0基础 VPC 网络工具结果GetTemplateEstimateCost"}, + {"index": "1", "text": "方案 1VPC 含可用区交换机模板校验校验模板参数"}, + ] + assert "使用 192.168.0.0/16 网段" not in output["stepText"] + assert "创建 VPC 和可用区交换机" not in output["stepText"] + + +def test_controller_renders_generic_pending_input_options_in_left_chat() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + input: { + kind: "ask_user_question", + inputId: "ask-1", + question: "请选择部署目标", + options: [ + {id: "nginx", label: "Nginx 网站", description: "托管静态站点"}, + {id: "api", label: "API 服务", description: "后端接口"} + ] + } + }}} +}); +const cards = all("[data-pending-input-kind]"); +const options = all("[data-pending-input-option]").map((option) => ({ + id: option.getAttribute("data-pending-input-option"), + text: text(option) +})); +all("[data-pending-input-option]")[1].click(); +const optionsAfter = all("[data-pending-input-option]").map((option) => ({ + id: option.getAttribute("data-pending-input-option"), + pressed: option.getAttribute("aria-pressed"), + className: option.getAttribute("class") +})); +return { + cardCount: cards.length, + options, + optionsAfter, + pendingKind: debug.state().pendingInput.kind, + pendingPrompt: debug.state().pendingInput.prompt, + pendingOptionCount: debug.state().pendingInput.options.length, + selectedPendingInputOptionId: debug.state().selectedPendingInputOptionId, + composerValue: elementById("composer-input").value +}; +""" + ) + + assert output == { + "cardCount": 1, + "options": [ + {"id": "nginx", "text": "Nginx 网站托管静态站点"}, + {"id": "api", "text": "API 服务后端接口"}, + ], + "optionsAfter": [ + {"id": "nginx", "pressed": "false", "className": "pending-input-option"}, + {"id": "api", "pressed": "true", "className": "pending-input-option selected"}, + ], + "pendingKind": "ask_user_question", + "pendingPrompt": "请选择部署目标", + "pendingOptionCount": 2, + "selectedPendingInputOptionId": "api", + "composerValue": "api", + } + + +def test_controller_renders_pending_input_markdown_for_questions_and_candidate_selection() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "intent_parsing"}, + input: { + kind: "ask_user_question", + question: "请选择 **部署目标**:\\n\\n- Nginx 网站\\n- API 服务\\n\\n查看 [帮助](https://example.com/docs)", + options: [ + {id: "nginx", label: "Nginx 网站", description: "用于 **静态站点**"} + ] + } + }}} +}); +const askCard = all("[data-pending-input-kind]")[0]; +const askMarkdown = all("[data-markdown-node]").map((node) => ({ + kind: node.getAttribute("data-markdown-node"), + tag: node.tagName, + text: text(node), + href: node.getAttribute("href") +})); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + input: { + kind: "candidate_select", + question: "请选择要部署的方案:**方案 0** 或 **方案 1**", + options: [ + {id: "0", label: "经济型方案", description: "适合 **低成本** 演示"} + ] + } + }}} +}); +const candidateCard = all("[data-pending-input-kind]")[0]; +const candidateMarkdown = all("[data-markdown-node]").map((node) => ({ + kind: node.getAttribute("data-markdown-node"), + tag: node.tagName, + text: text(node), + href: node.getAttribute("href") +})); +return { + askText: text(askCard), + askMarkdown, + candidateText: text(candidateCard), + candidateMarkdown +}; +""" + ) + + assert "**部署目标**" not in output["askText"] + assert "查看 帮助" in output["askText"] + assert {"kind": "strong", "tag": "STRONG", "text": "部署目标", "href": None} in output["askMarkdown"] + assert {"kind": "li", "tag": "LI", "text": "Nginx 网站", "href": None} in output["askMarkdown"] + assert {"kind": "a", "tag": "A", "text": "帮助", "href": "https://example.com/docs"} in output["askMarkdown"] + assert "**方案 0**" not in output["candidateText"] + assert "方案 0" in output["candidateText"] + assert {"kind": "strong", "tag": "STRONG", "text": "方案 0", "href": None} in output["candidateMarkdown"] + assert {"kind": "strong", "tag": "STRONG", "text": "低成本", "href": None} in output["candidateMarkdown"] + + +def test_controller_renders_inline_numbered_pending_input_as_ordered_list() -> None: + output = controller_harness( + """ +controller.init(); +const next = reducers.reducePipelinePayload(debug.state(), { + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "intent_parsing"}, + input: { + kind: "ask_user_question", + question: "请补充以下信息: 1. 演示内容:**静态页面**、反向代理还是其他? 2. 是否需要公网访问? 3. 预算偏好?" + } + }}} +}); +Object.assign(debug.state(), next); +debug.render(); +const card = all("[data-pending-input-kind]")[0]; +const markdown = all("[data-markdown-node]").map((node) => ({ + kind: node.getAttribute("data-markdown-node"), + tag: node.tagName, + text: text(node) +})); +return {cardText: text(card), markdown}; +""" + ) + + assert "1. 演示内容" not in output["cardText"] + assert { + "kind": "ol", + "tag": "OL", + "text": "演示内容:静态页面、反向代理还是其他?是否需要公网访问?预算偏好?", + } in output["markdown"] + assert {"kind": "li", "tag": "LI", "text": "演示内容:静态页面、反向代理还是其他?"} in output["markdown"] + assert {"kind": "li", "tag": "LI", "text": "是否需要公网访问?"} in output["markdown"] + assert {"kind": "li", "tag": "LI", "text": "预算偏好?"} in output["markdown"] + assert {"kind": "strong", "tag": "STRONG", "text": "静态页面"} in output["markdown"] + + +def test_controller_ask_user_question_candidate_option_syncs_with_right_plan_card() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +[ + {index: 0, name: "经济型演示方案", summary: "成本最低", price: "¥74/月"}, + {index: 1, name: "均衡型演示方案", summary: "性能稳定", price: "¥291/月"} +].forEach((candidate) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "confirm_and_select"}, + candidate: {index: candidate.index}, + data: { + detail: { + candidateName: candidate.name, + candidateIndex: candidate.index, + summary: candidate.summary, + totalMonthlyCost: candidate.price + } + } + }}} +})); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + input: { + kind: "ask_user_question", + question: "请选择要部署的方案", + options: [ + {id: "use-economy", label: "选择经济型", candidate_index: 0}, + {id: "use-balanced", label: "选择均衡型", candidate_index: 1} + ] + } + }}} +}); +all("[data-pending-input-option]") + .find((option) => option.getAttribute("data-pending-input-option") === "use-balanced") + .click(); +const leftOptions = all("[data-pending-input-option]").map((option) => ({ + id: option.getAttribute("data-pending-input-option"), + candidateChoice: option.getAttribute("data-candidate-choice"), + pressed: option.getAttribute("aria-pressed"), + className: option.getAttribute("class") +})); +const rightCards = all("[data-candidate-index]").map((card) => ({ + index: card.getAttribute("data-candidate-index"), + pressed: card.getAttribute("aria-pressed"), + className: card.getAttribute("class") +})); +return { + leftOptions, + rightCards, + selectedCandidateIndex: debug.state().selectedCandidateIndex, + selectedPendingInputOptionId: debug.state().selectedPendingInputOptionId, + composerValue: elementById("composer-input").value +}; +""" + ) + + assert output == { + "leftOptions": [ + { + "id": "use-economy", + "candidateChoice": "0", + "pressed": "false", + "className": "pending-input-option", + }, + { + "id": "use-balanced", + "candidateChoice": "1", + "pressed": "true", + "className": "pending-input-option selected", + }, + ], + "rightCards": [ + {"index": "0", "pressed": "false", "className": "plan-card"}, + {"index": "1", "pressed": "true", "className": "plan-card selected recommended"}, + ], + "selectedCandidateIndex": 1, + "selectedPendingInputOptionId": "use-balanced", + "composerValue": "use-balanced", + } + + +def test_controller_renders_candidate_selection_pending_input_as_options() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "confirm_and_select"}, + candidate: {index: 1}, + data: { + detail: { + candidateName: "轻量应用服务器", + candidateIndex: 1, + summary: "开箱即用", + totalMonthlyCost: "¥0/月", + costItems: [] + } + } + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + data: { + kind: "candidate_selection", + prompt: "请选择购买方案", + options: [{id: "1", label: "轻量应用服务器", summary: "开箱即用", totalMonthlyCost: "¥0/月"}] + } + }}} +}); +const cards = all("[data-pending-input-kind]"); +const options = all("[data-pending-input-option]"); +const planCard = all("[data-candidate-index]")[0]; +const stepText = text(all("[data-step-id]").find((step) => step.getAttribute("data-step-id") === "confirm_and_select")); +options[0].click(); +const selectedPlanText = text(all("[data-candidate-index]")[0]); +return { + cardCount: cards.length, + optionCount: options.length, + stepText, + planText: selectedPlanText, + selectedCandidateIndex: debug.state().selectedCandidateIndex, + composerValue: elementById("composer-input").value +}; +""" + ) + + assert output["cardCount"] == 1 + assert output["optionCount"] == 1 + assert "请选择购买方案" in output["stepText"] + assert "轻量应用服务器" in output["stepText"] + assert "开箱即用" in output["stepText"] + assert "¥0/月" in output["stepText"] + assert "思考过程" in output["stepText"] + assert output["planText"] == "已选方案 1轻量应用服务器开箱即用预估价格¥0/月" + assert output["selectedCandidateIndex"] == 1 + assert output["composerValue"] == "选择方案1" + + +def test_controller_step_four_selection_ui_syncs_with_right_plan_cards() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +[ + {index: 0, name: "基础 VPC", summary: "成本最低", price: "¥0/月"}, + {index: 1, name: "VPC 含交换机", summary: "部署更完整", price: "¥0/月"} +].forEach((candidate) => applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "confirm_and_select"}, + candidate: {index: candidate.index}, + data: { + detail: { + candidateName: candidate.name, + candidateIndex: candidate.index, + summary: candidate.summary, + totalMonthlyCost: candidate.price + } + } + }}} +})); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + data: { + kind: "candidate_selection", + prompt: "请选择购买方案", + options: [ + {id: "0", label: "基础 VPC", summary: "成本最低", totalMonthlyCost: "¥0/月"}, + {id: "1", label: "VPC 含交换机", summary: "部署更完整", totalMonthlyCost: "¥0/月"} + ] + } + }}} +}); +const choicesBefore = all("[data-candidate-choice]").map((choice) => ({ + index: choice.getAttribute("data-candidate-choice"), + pressed: choice.getAttribute("aria-pressed"), + text: text(choice) +})); +all("[data-candidate-choice]") + .find((choice) => choice.getAttribute("data-candidate-choice") === "1") + .click(); +const choicesAfter = all("[data-candidate-choice]").map((choice) => ({ + index: choice.getAttribute("data-candidate-choice"), + pressed: choice.getAttribute("aria-pressed"), + className: choice.getAttribute("class") +})); +const rightCards = all("[data-candidate-index]").map((card) => ({ + index: card.getAttribute("data-candidate-index"), + pressed: card.getAttribute("aria-pressed"), + className: card.getAttribute("class") +})); +return { + choicesBefore, + choicesAfter, + rightCards, + selectedCandidateIndex: debug.state().selectedCandidateIndex, + composerValue: elementById("composer-input").value +}; +""" + ) + + assert [choice["index"] for choice in output["choicesBefore"]] == ["0", "1"] + assert "基础 VPC" in output["choicesBefore"][0]["text"] + assert "VPC 含交换机" in output["choicesBefore"][1]["text"] + assert output["choicesAfter"] == [ + {"index": "0", "pressed": "false", "className": "pending-input-option"}, + {"index": "1", "pressed": "true", "className": "pending-input-option selected"}, + ] + assert output["rightCards"] == [ + {"index": "0", "pressed": "false", "className": "plan-card"}, + {"index": "1", "pressed": "true", "className": "plan-card selected recommended"}, + ] + assert output["selectedCandidateIndex"] == 1 + assert output["composerValue"] == "选择方案1" + + +def test_controller_step_four_waiting_input_keeps_thinking_process_available() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +[ + {eventType: "step_started", data: {summary: "准备选择方案"}}, + {eventType: "text_delta", data: {text: "比较方案偏好"}}, + {eventType: "input_required", data: { + kind: "candidate_selection", + prompt: "请选择购买方案", + options: [{id: "0", label: "基础 VPC", summary: "成本最低"}] + }} +].forEach((event) => applyPayload({ + metadata: {iac_code: {pipeline: { + ...event, + status: event.eventType === "input_required" ? "input_required" : "working", + step: {id: "confirm_and_select"} + }}} +})); +const step = all("[data-step-id]").find((item) => item.getAttribute("data-step-id") === "confirm_and_select"); +const process = all("[data-step-process]") + .find((item) => item.getAttribute("data-step-process") === "confirm_and_select"); +return { + stepText: text(step), + processText: text(process), + processEvents: all("[data-step-process-event]").map((item) => item.getAttribute("data-step-process-event")) +}; +""" + ) + + assert "请选择购买方案" in output["stepText"] + assert "基础 VPC" in output["stepText"] + assert "思考过程" in output["processText"] + assert output["processEvents"] == ["step_started", "text_delta", "input_required"] + + +def test_controller_accepts_candidate_select_pending_input_alias() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "confirm_and_select"}, + candidate: {index: 1}, + data: {detail: {candidateName: "轻量应用服务器", candidateIndex: 1}} + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + data: { + kind: "candidate_select", + prompt: "请选择购买方案", + options: [{id: "1", label: "轻量应用服务器"}] + } + }}} +}); +const cards = all("[data-pending-input-kind]"); +const options = all("[data-pending-input-option]"); +options[0].click(); +return { + cardCount: cards.length, + optionCount: options.length, + selectedCandidateIndex: debug.state().selectedCandidateIndex, + composerValue: elementById("composer-input").value +}; +""" + ) + + assert output == { + "cardCount": 1, + "optionCount": 1, + "selectedCandidateIndex": 1, + "composerValue": "选择方案1", + } + + +def test_controller_candidate_select_uses_candidate_index_when_option_id_is_not_numeric() -> None: + output = controller_harness( + """ +controller.init(); +function applyPayload(payload) { + const next = reducers.reducePipelinePayload(debug.state(), payload); + Object.assign(debug.state(), next); + debug.render(); +} +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "candidate_detail_shown", + status: "working", + step: {id: "confirm_and_select"}, + candidate: {index: 1}, + data: { + detail: { + candidateName: "均衡型演示方案", + candidateIndex: 1, + summary: "性能稳定", + totalMonthlyCost: "¥291/月" + } + } + }}} +}); +applyPayload({ + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + data: { + kind: "candidate_select", + prompt: "请选择购买方案", + options: [{id: "balanced-plan", label: "均衡型演示方案", candidate_index: 1}] + } + }}} +}); +all("[data-pending-input-option]")[0].click(); +const option = all("[data-pending-input-option]")[0]; +const plan = all("[data-candidate-index]")[0]; +return { + optionId: option.getAttribute("data-pending-input-option"), + candidateChoice: option.getAttribute("data-candidate-choice"), + optionPressed: option.getAttribute("aria-pressed"), + optionClass: option.getAttribute("class"), + planPressed: plan.getAttribute("aria-pressed"), + planClass: plan.getAttribute("class"), + selectedCandidateIndex: debug.state().selectedCandidateIndex, + selectedPendingInputOptionId: debug.state().selectedPendingInputOptionId, + composerValue: elementById("composer-input").value +}; +""" + ) + + assert output == { + "optionId": "balanced-plan", + "candidateChoice": "1", + "optionPressed": "true", + "optionClass": "pending-input-option selected", + "planPressed": "true", + "planClass": "plan-card selected recommended", + "selectedCandidateIndex": 1, + "selectedPendingInputOptionId": "balanced-plan", + "composerValue": "选择方案1", + } + + +def test_controller_candidate_choices_show_in_left_chat_and_sync_with_right_cards() -> None: + output = controller_harness( + """ +controller.init(); +debug.loadDemoCandidates(); +const leftChoiceCountBefore = all("[data-pending-input-option]").length; +all("[data-pending-input-option]")[1].click(); +const leftChoiceCountAfter = all("[data-pending-input-option]").length; +const planCards = all("[data-candidate-index]").map((card) => ({ + index: card.getAttribute("data-candidate-index"), + pressed: card.getAttribute("aria-pressed") +})); +return { + leftChoiceCountBefore, + leftChoiceCountAfter, + selectedCandidateIndex: debug.state().selectedCandidateIndex, + selectedPlan: planCards.find((card) => card.index === "1"), + prompt: reducers.promptForSelectedCandidate(debug.state()) +}; +""" + ) + + assert output == { + "leftChoiceCountBefore": 2, + "leftChoiceCountAfter": 2, + "selectedCandidateIndex": 1, + "selectedPlan": { + "index": "1", + "pressed": "true", + }, + "prompt": "选择方案1", + } + + +def test_controller_shows_normal_chat_notice_in_dialog_after_pipeline_handoff() -> None: + output = controller_harness( + """ +controller.init(); +const next = reducers.reducePipelinePayload(debug.state(), { + metadata: {iac_code: {pipeline: { + eventType: "pipeline_handoff_ready", + status: "completed", + contextId: "ctx-1", + taskId: "task-pipeline", + data: {action: "switch_to_normal", targetMode: "normal"} + }}} +}); +Object.assign(debug.state(), next); +debug.render(); +const messages = all("[data-normal-handoff-message]").map((item) => text(item)); +return { + messages, + composerNoticeHidden: elementById("normal-handoff-notice").hidden, + activeTaskId: debug.state().activeTaskId, + normalHandoffReady: debug.state().normalHandoffReady +}; +""" + ) + + assert output == { + "messages": ["部署流程已完成,已进入普通会话。可以继续追问资源、运维或变更需求。"], + "composerNoticeHidden": True, + "activeTaskId": "", + "normalHandoffReady": True, + } + + +def test_controller_renders_debug_session_info_for_issue_reports() -> None: + output = controller_harness( + """ +controller.init(); +Object.assign(debug.state(), { + contextId: "ctx-1", + pipelineTaskId: "task-pipeline", + activeTaskId: "task-active", + lastSequence: 42, + status: "working", + normalHandoffReady: false +}); +debug.render(); +const fields = all("[data-debug-session-field]").map((field) => ({ + key: field.getAttribute("data-debug-session-field"), + text: text(field) +})); +return {fields}; +""" + ) + + assert output["fields"] == [ + {"key": "serverUrl", "text": "Server URLhttp://127.0.0.1:41299"}, + {"key": "cwd", "text": "CWD/workspace"}, + {"key": "contextId", "text": "Context IDctx-1"}, + {"key": "pipelineTaskId", "text": "Pipeline Tasktask-pipeline"}, + {"key": "activeTaskId", "text": "Active Tasktask-active"}, + {"key": "lastSequence", "text": "Last Sequence42"}, + {"key": "status", "text": "Statusworking"}, + {"key": "handoff", "text": "Normal Handoff否"}, + {"key": "logs", "text": "Logs默认 ~/.iac-code/logs,或 IAC_CODE_CONFIG_DIR/logs"}, + ] + + +def test_controller_plan_card_selection_updates_left_candidate_choice() -> None: + output = controller_harness( + """ +controller.init(); +debug.loadDemoCandidates(); +all("[data-candidate-index]")[1].click(); +return { + leftChoices: all("[data-candidate-choice]").map((choice) => ({ + index: choice.getAttribute("data-candidate-choice"), + pressed: choice.getAttribute("aria-pressed") + })), + rightCards: all("[data-candidate-index]").map((card) => ({ + index: card.getAttribute("data-candidate-index"), + pressed: card.getAttribute("aria-pressed") + })), + prompt: reducers.promptForSelectedCandidate(debug.state()) +}; +""" + ) + + assert output == { + "leftChoices": [ + {"index": "0", "pressed": "false"}, + {"index": "1", "pressed": "true"}, + ], + "rightCards": [ + {"index": "0", "pressed": "false"}, + {"index": "1", "pressed": "true"}, + ], + "prompt": "选择方案1", + } + + +def test_controller_reports_sse_error_event_as_failed_send() -> None: + output = controller_harness( + """ +controller.init(); +elementById("composer-input").value = "继续部署"; +global.fetch = async () => ({ + ok: true, + status: 200, + body: null, + text: async () => 'data: {"ok": false, "error": "upstream timed out"}\\n\\n' +}); +await controller.sendComposerMessage(); +return { + alertText: elementById("status-alert").textContent, + alertKind: elementById("status-alert").getAttribute("data-kind"), + debug: debugText() +}; +""" + ) + + assert output["alertText"] == "消息发送失败:upstream timed out" + assert output["alertKind"] == "error" + assert "upstream timed out" in output["debug"] + + +def test_controller_yields_between_sse_blocks_so_streaming_can_paint_incrementally() -> None: + output = controller_harness( + """ +controller.init(); +debug.state().progressUi.variant = "a"; +elementById("composer-input").value = "创建 VPC"; +let paintCount = 0; +global.requestAnimationFrame = (callback) => { + paintCount += 1; + return setTimeout(() => callback(Date.now()), 0); +}; +global.cancelAnimationFrame = (id) => clearTimeout(id); +const encoder = new TextEncoder(); +let readCount = 0; +global.fetch = async () => ({ + ok: true, + status: 200, + body: { + getReader() { + return { + async read() { + readCount += 1; + if (readCount === 1) { + return { + done: false, + value: encoder.encode([ + 'data: {"metadata":{"iac_code":{"pipeline":' + + '{"eventType":"step_started","status":"working","step":{"id":"intent_parsing"},' + + '"data":{"summary":"开始理解需求"}}}}}', + 'data: {"metadata":{"iac_code":{"pipeline":' + + '{"eventType":"text_delta","status":"working","step":{"id":"intent_parsing"},' + + '"data":{"text":"正在分析预算"}}}}}' + ].join("\\n\\n") + "\\n\\n") + }; + } + return {done: true}; + }, + async cancel() {} + }; + } + } +}); +await controller.sendComposerMessage(); +return { + paintCount, + cardKinds: all("[data-step-event-kind]").map((item) => item.getAttribute("data-step-event-kind")) +}; +""" + ) + + assert output["paintCount"] >= 2 + assert output["cardKinds"] == ["step_started", "text_delta"] + + +def test_reducer_deep_clones_existing_permission_and_diagnostics() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.permission = {decision: {allowed: true}}; +state.diagnostics = { + requests: [{meta: {id: "req-1"}}], + sse: [{meta: {id: "sse-1"}}], + snapshots: [{meta: {id: "snap-1"}}] +}; +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + step: {id: "architecture_planning", status: "completed"} + }}} +}); +state.permission.decision.allowed = false; +state.diagnostics.requests[0].meta.id = "mutated"; +state.diagnostics.sse[0].meta.id = "mutated"; +state.diagnostics.snapshots[0].meta.id = "mutated"; +return { + permissionAllowed: next.permission.decision.allowed, + requestId: next.diagnostics.requests[0].meta.id, + sseId: next.diagnostics.sse[0].meta.id, + snapshotId: next.diagnostics.snapshots[0].meta.id +}; +""" + ) + + assert output == { + "permissionAllowed": True, + "requestId": "req-1", + "sseId": "sse-1", + "snapshotId": "snap-1", + } + + +def test_candidate_from_display_item_deep_clones_cost_item_nested_fields() -> None: + output = reducer_harness( + """ +const source = { + candidateName: "方案", + candidateIndex: 0, + costItems: [{name: "ecs", detail: {region: "cn-hangzhou"}}] +}; +const candidate = reducers.candidateFromDisplayItem(source); +source.costItems[0].detail.region = "mutated"; +return { + name: candidate.name, + region: candidate.costItems[0].detail.region +}; +""" + ) + + assert output == { + "name": "方案", + "region": "cn-hangzhou", + } + + +def test_reducer_sets_and_clears_realtime_pending_input() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +const waiting = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + taskId: "task-1", + contextId: "ctx-1", + input: { + inputId: "ask-1", + kind: "ask_user_question", + question: "请选择部署目标", + options: [{id: "nginx", label: "Nginx 网站"}] + } + }}} +}); +const received = reducers.reducePipelinePayload(waiting, { + metadata: {iac_code: {pipeline: { + eventType: "input_received", + status: "working", + taskId: "task-1", + contextId: "ctx-1" + }}} +}); +return { + prompt: waiting.pendingInput.prompt, + optionLabel: waiting.pendingInput.options[0].label, + candidateCount: waiting.candidates.length, + originalPending: state.pendingInput, + waitingStatus: waiting.status, + cleared: received.pendingInput === null +}; +""" + ) + + assert output == { + "prompt": "请选择部署目标", + "optionLabel": "Nginx 网站", + "candidateCount": 0, + "originalPending": None, + "waitingStatus": "waiting_input", + "cleared": True, + } + + +def test_reducer_does_not_turn_pending_input_data_options_into_candidates() -> None: + output = reducer_harness( + """ +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + data: { + question: "请选择部署目标", + options: [{id: "nginx", label: "Nginx 网站"}] + } + }}} +}); +return { + candidateCount: next.candidates.length, + prompt: next.pendingInput.prompt, + optionLabel: next.pendingInput.options[0].label +}; +""" + ) + + assert output == { + "candidateCount": 0, + "prompt": "请选择部署目标", + "optionLabel": "Nginx 网站", + } + + +def test_reducer_collects_candidate_selection_options_from_pending_input() -> None: + output = reducer_harness( + """ +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + step: {id: "confirm_and_select"}, + data: { + kind: "candidate_selection", + prompt: "请选择购买方案", + options: [{ + id: "1", + label: "标准 VPC", + description: "在 cn-hangzhou 创建一个标准 VPC,使用默认网段 172.16.0.0/12。", + price: "¥0/月" + }] + } + }}} +}); +return { + candidateCount: next.candidates.length, + name: next.candidates[0] && next.candidates[0].name, + index: next.candidates[0] && next.candidates[0].candidateIndex, + summary: next.candidates[0] && next.candidates[0].summary, + cost: next.candidates[0] && next.candidates[0].totalMonthlyCost, + pendingPrompt: next.pendingInput.prompt +}; +""" + ) + + assert output == { + "candidateCount": 1, + "name": "标准 VPC", + "index": 1, + "summary": "在 cn-hangzhou 创建一个标准 VPC,使用默认网段 172.16.0.0/12。", + "cost": "¥0/月", + "pendingPrompt": "请选择购买方案", + } + + +def test_reducer_deep_clones_realtime_pending_input_payload() -> None: + output = reducer_harness( + """ +const input = { + question: "请选择部署目标", + extra: {source: "planner"}, + options: [{id: "nginx", label: "Nginx 网站", meta: {score: 1}}] +}; +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + taskId: "task-1", + contextId: "ctx-1", + input + }}} +}); +input.extra.source = "mutated"; +input.options[0].meta.score = 99; +return { + prompt: next.pendingInput.prompt, + source: next.pendingInput.extra.source, + score: next.pendingInput.options[0].meta.score +}; +""" + ) + + assert output == { + "prompt": "请选择部署目标", + "source": "planner", + "score": 1, + } + + +def test_reducer_handles_snake_case_input_required_envelope() -> None: + output = reducer_harness( + """ +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + metadata: {iac_code: {pipeline: { + event_type: "input_required", + status: "input_required", + task_id: "task-1", + context_id: "ctx-1", + pending_input: { + question: "请选择部署目标", + options: [{id: "nginx", label: "Nginx 网站"}] + } + }}} +}); +return { + taskId: next.pipelineTaskId, + contextId: next.contextId, + status: next.status, + prompt: next.pendingInput && next.pendingInput.prompt +}; +""" + ) + + assert output == { + "taskId": "task-1", + "contextId": "ctx-1", + "status": "waiting_input", + "prompt": "请选择部署目标", + } + + +def test_reducer_extracts_realtime_envelope_from_a2a_status_update_wrapper() -> None: + output = reducer_harness( + """ +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + result: { + statusUpdate: { + metadata: {iac_code: {pipeline: { + eventType: "input_required", + status: "input_required", + taskId: "task-1", + contextId: "ctx-1", + input: { + question: "请选择部署目标", + options: [{id: "nginx", label: "Nginx 网站"}] + } + }}} + } + } +}); +return { + taskId: next.pipelineTaskId, + contextId: next.contextId, + status: next.status, + prompt: next.pendingInput && next.pendingInput.prompt +}; +""" + ) + + assert output == { + "taskId": "task-1", + "contextId": "ctx-1", + "status": "waiting_input", + "prompt": "请选择部署目标", + } + + +def test_reducer_restores_pipeline_state_snapshot_and_applies_events() -> None: + output = reducer_harness( + """ +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + snapshot: { + taskId: "task-1", + contextId: "ctx-1", + lastSequence: 7, + status: "working", + steps: [{id: "architecture_planning", status: "completed"}] + }, + events: [{ + eventType: "step_completed", + status: "working", + taskId: "task-1", + contextId: "ctx-1", + sequence: 8, + step: {id: "evaluate_candidates", status: "completed"} + }] +}); +return { + taskId: next.pipelineTaskId, + contextId: next.contextId, + lastSequence: next.lastSequence, + architectureStatus: next.steps.architecture_planning.status, + evaluateStatus: next.steps.evaluate_candidates.status, + evaluateEvents: next.steps.evaluate_candidates.events.length +}; +""" + ) + + assert output == { + "taskId": "task-1", + "contextId": "ctx-1", + "lastSequence": 8, + "architectureStatus": "completed", + "evaluateStatus": "completed", + "evaluateEvents": 1, + } + + +def test_reducer_does_not_roll_back_last_sequence_from_replay_event() -> None: + output = reducer_harness( + """ +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), { + snapshot: { + task_id: "task-1", + context_id: "ctx-1", + last_sequence: "10", + status: "working" + }, + events: [{ + event_type: "step_completed", + sequence: 8, + step: {id: "evaluate_candidates", status: "completed"} + }] +}); +return { + lastSequence: next.lastSequence, + evaluateEvents: next.steps.evaluate_candidates.events.length +}; +""" + ) + + assert output == { + "lastSequence": 10, + "evaluateEvents": 1, + } + + +def test_reducer_snapshot_pending_input_null_clears_stale_pending_input() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.pendingInput = {prompt: "旧问题", options: [{id: "old", label: "旧选项"}]}; +const next = reducers.reducePipelinePayload(state, { + snapshot: {status: "working", pendingInput: null} +}); +return { + originalPrompt: state.pendingInput.prompt, + nextPending: next.pendingInput +}; +""" + ) + + assert output == { + "originalPrompt": "旧问题", + "nextPending": None, + } + + +def test_reducer_snapshot_normal_handoff_switches_to_normal_mode() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.activeTaskId = "pipeline-task"; +const next = reducers.reducePipelinePayload(state, { + snapshot: { + status: "completed", + normalHandoff: {action: "switch_to_normal", targetMode: "normal"} + } +}); +return { + normalHandoffReady: next.normalHandoffReady, + activeTaskId: next.activeTaskId, + status: next.status, + originalActiveTaskId: state.activeTaskId +}; +""" + ) + + assert output == { + "normalHandoffReady": True, + "activeTaskId": "", + "status": "completed", + "originalActiveTaskId": "pipeline-task", + } + + +def test_reducer_snapshot_snake_case_normal_handoff_switches_to_normal_mode() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.activeTaskId = "pipeline-task"; +const next = reducers.reducePipelinePayload(state, { + snapshot: { + status: "completed", + normal_handoff: {action: "switch_to_normal", target_mode: "normal"} + } +}); +return { + normalHandoffReady: next.normalHandoffReady, + activeTaskId: next.activeTaskId, + status: next.status, + originalActiveTaskId: state.activeTaskId +}; +""" + ) + + assert output == { + "normalHandoffReady": True, + "activeTaskId": "", + "status": "completed", + "originalActiveTaskId": "pipeline-task", + } + + +def test_reducer_attaches_pipeline_scoped_events_to_current_step() -> None: + output = reducer_harness( + """ +let state = reducers.createInitialState({}); +[ + { + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "deploying"}, + data: {summary: "开始部署"} + }}} + }, + { + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "working", + scope: "pipeline", + data: {text: "开始部署流程"} + }}} + }, + { + metadata: {iac_code: {pipeline: { + eventType: "permission_requested", + status: "working", + scope: "pipeline", + data: {toolName: "ros_stack", reason: "创建资源栈"} + }}} + }, + { + metadata: {iac_code: {pipeline: { + eventType: "tool_result", + status: "working", + scope: "pipeline", + data: {toolName: "ros_stack", result: {stackId: "stack-1", stackStatus: "CREATE_COMPLETE"}} + }}} + }, + { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "completed", + step: {id: "deploying"}, + data: {conclusion: {summary: "部署完成"}} + }}} + } +].forEach((payload) => { + state = reducers.reducePipelinePayload(state, payload); +}); +return { + currentStepId: state.currentStepId, + deployingEvents: state.steps.deploying.events.map((event) => event.eventType) +}; +""" + ) + + assert output == { + "currentStepId": "deploying", + "deployingEvents": ["step_started", "text_delta", "permission_requested", "tool_result", "step_completed"], + } + + +def test_reducer_does_not_attach_pipeline_scoped_events_to_completed_step() -> None: + output = reducer_harness( + """ +let state = reducers.createInitialState({}); +[ + { + metadata: {iac_code: {pipeline: { + eventType: "step_started", + status: "working", + step: {id: "deploying"}, + data: {summary: "开始部署"} + }}} + }, + { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + status: "completed", + step: {id: "deploying"}, + data: {conclusion: {summary: "部署完成"}} + }}} + }, + { + metadata: {iac_code: {pipeline: { + eventType: "text_delta", + status: "completed", + scope: "pipeline", + data: {text: "流程结束后的普通消息"} + }}} + } +].forEach((payload) => { + state = reducers.reducePipelinePayload(state, payload); +}); +return { + currentStepId: state.currentStepId, + deployingEvents: state.steps.deploying.events.map((event) => event.eventType || event.event_type) +}; +""" + ) + + assert output == { + "currentStepId": "deploying", + "deployingEvents": ["step_started", "step_completed"], + } + + +def test_reducer_applies_raw_snapshot_like_payload_with_snake_case_aliases() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.activeTaskId = "pipeline-task"; +state.pendingInput = {prompt: "旧问题", options: [{id: "old", label: "旧选项"}]}; +const next = reducers.reducePipelinePayload(state, { + status: "input_required", + task_id: "task-1", + context_id: "ctx-1", + last_sequence: "12", + pending_input: { + question: "请选择部署目标", + options: [{id: "nginx", label: "Nginx 网站"}] + }, + normal_handoff: {action: "switch_to_normal", target_mode: "normal"} +}); +return { + status: next.status, + taskId: next.pipelineTaskId, + contextId: next.contextId, + lastSequence: next.lastSequence, + prompt: next.pendingInput && next.pendingInput.prompt, + activeTaskId: next.activeTaskId, + normalHandoffReady: next.normalHandoffReady, + originalPrompt: state.pendingInput.prompt +}; +""" + ) + + assert output == { + "status": "waiting_input", + "taskId": "task-1", + "contextId": "ctx-1", + "lastSequence": 12, + "prompt": "请选择部署目标", + "activeTaskId": "", + "normalHandoffReady": True, + "originalPrompt": "旧问题", + } + + +def test_reducer_does_not_retain_mutable_candidate_payload_references() -> None: + output = reducer_harness( + """ +const costItems = [{name: "ecs"}]; +const payload = {snapshot: {display: {candidateDetails: [{ + candidateName: "方案", + candidateIndex: 0, + costItems +}]}}}; +const next = reducers.reducePipelinePayload(reducers.createInitialState({}), payload); +payload.snapshot.display.candidateDetails[0].candidateName = "被污染"; +costItems[0].name = "mutated"; +return { + name: next.candidates[0].name, + costItemName: next.candidates[0].costItems[0].name +}; +""" + ) + + assert output == { + "name": "方案", + "costItemName": "ecs", + } + + +def test_upsert_candidate_does_not_mutate_original_state() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.candidates = [{name: "旧方案", candidateIndex: 0, costItems: []}]; +const next = reducers.upsertCandidate(state, { + name: "新方案", + candidateIndex: 0, + totalMonthlyCost: "CNY 80", + costItems: [{name: "ecs"}] +}); +return { + sameState: next === state, + sameCandidates: next.candidates === state.candidates, + originalName: state.candidates[0].name, + originalCost: state.candidates[0].totalMonthlyCost || "", + nextName: next.candidates[0].name, + nextCost: next.candidates[0].totalMonthlyCost, + nextCostItem: next.candidates[0].costItems[0].name +}; +""" + ) + + assert output == { + "sameState": False, + "sameCandidates": False, + "originalName": "旧方案", + "originalCost": "", + "nextName": "新方案", + "nextCost": "CNY 80", + "nextCostItem": "ecs", + } + + +def test_upsert_candidate_deduplicates_numeric_string_candidate_index() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.candidates = [{name: "旧方案", candidateIndex: 1, costItems: []}]; +const next = reducers.upsertCandidate(state, { + name: "新方案", + candidateIndex: "1", + totalMonthlyCost: "¥0/月" +}); +return { + count: next.candidates.length, + index: next.candidates[0].candidateIndex, + name: next.candidates[0].name, + originalName: state.candidates[0].name +}; +""" + ) + + assert output == { + "count": 1, + "index": 1, + "name": "新方案", + "originalName": "旧方案", + } + + +def test_upsert_candidate_merges_indexed_detail_into_same_name_placeholder() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.candidates = [{ + name: "标准 VPC 网络", + candidateIndex: null, + summary: "", + totalMonthlyCost: "", + costItems: [] +}]; +const next = reducers.upsertCandidate(state, { + name: "标准 VPC 网络", + candidateIndex: 0, + summary: "仅创建 VPC,作为后续子网和云资源的网络容器。", + totalMonthlyCost: "¥33.89/月", + costItems: [{name: "VPC", monthly_cost: "免费"}] +}); +const candidate = next.candidates.find((item) => item.candidateIndex === 0) || next.candidates[0]; +return { + count: next.candidates.length, + index: candidate.candidateIndex, + name: candidate.name, + summary: candidate.summary, + cost: candidate.totalMonthlyCost, + costItem: candidate.costItems[0] && candidate.costItems[0].name +}; +""" + ) + + assert output == { + "count": 1, + "index": 0, + "name": "标准 VPC 网络", + "summary": "仅创建 VPC,作为后续子网和云资源的网络容器。", + "cost": "¥33.89/月", + "costItem": "VPC", + } + + +def test_upsert_candidate_does_not_overwrite_existing_detail_with_empty_fields() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState({}); +state.candidates = [{ + name: "VPC 含可用区交换机", + candidateIndex: 1, + summary: "创建 VPC 及一个可用区交换机,开箱即用。", + totalMonthlyCost: "¥0/月", + costItems: [{name: "VPC", monthly_cost: "免费"}] +}]; +const next = reducers.upsertCandidate(state, { + name: "VPC 含可用区交换机", + candidateIndex: 1, + summary: "", + totalMonthlyCost: "", + costItems: [] +}); +return { + count: next.candidates.length, + summary: next.candidates[0].summary, + cost: next.candidates[0].totalMonthlyCost, + costItemCount: next.candidates[0].costItems.length +}; +""" + ) + + assert output == { + "count": 1, + "summary": "创建 VPC 及一个可用区交换机,开箱即用。", + "cost": "¥0/月", + "costItemCount": 1, + } + + +def test_extract_pipeline_envelope_handles_snapshot_metadata_wrapper() -> None: + output = reducer_harness( + """ +const envelope = reducers.extractPipelineEnvelope({ + snapshot: { + metadata: {iac_code: {pipeline: { + eventType: "step_completed", + taskId: "task-1", + contextId: "ctx-1", + step: {id: "architecture_planning"} + }}} + } +}); +return { + taskId: envelope.taskId, + contextId: envelope.contextId, + stepId: envelope.step.id +}; +""" + ) + + assert output == { + "taskId": "task-1", + "contextId": "ctx-1", + "stepId": "architecture_planning", + } + + +def test_reducer_clears_active_task_on_normal_handoff() -> None: + output = reducer_harness( + """ +const state = reducers.createInitialState(); +state.pipelineTaskId = "pipeline-task"; +state.activeTaskId = "pipeline-task"; +const next = reducers.reducePipelinePayload(state, { + metadata: {iac_code: {pipeline: { + eventType: "pipeline_handoff_ready", + taskId: "pipeline-task", + contextId: "ctx-1", + status: "completed", + data: {action: "switch_to_normal", targetMode: "normal"} + }}} +}); +return { + normalHandoffReady: next.normalHandoffReady, + activeTaskId: next.activeTaskId, + contextId: next.contextId +}; +""" + ) + + assert output == { + "normalHandoffReady": True, + "activeTaskId": "", + "contextId": "ctx-1", + } diff --git a/tests/a2a/test_selling_console_script.py b/tests/a2a/test_selling_console_script.py new file mode 100644 index 00000000..9c160a4a --- /dev/null +++ b/tests/a2a/test_selling_console_script.py @@ -0,0 +1,1054 @@ +from __future__ import annotations + +import html as html_lib +import importlib.util +import json +import os +import shutil +import socket +import subprocess +import sys +import threading +from contextlib import contextmanager +from http.client import RemoteDisconnected +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.error import HTTPError +from urllib.parse import urlencode, urlparse +from urllib.request import Request, urlopen + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "a2a" / "selling_console.py" +SCRIPTS_README_PATH = Path(__file__).resolve().parents[2] / "scripts" / "README.md" +NODE_RELATIVE_PATH = Path(".cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node") +RECOVERABLE_JSONRPC_ERROR = { + "jsonrpc": "2.0", + "id": "1", + "error": { + "code": -32602, + "message": "Pipeline already running.", + "data": { + "recoverableTaskId": "task-owner", + "contextId": "ctx-1", + "sidecarStatus": "running", + }, + }, +} + + +def bundled_node_candidates() -> list[Path]: + override = os.environ.get("IAC_CODE_TEST_NODE") + if override: + return [Path(override).expanduser()] + candidates = [Path.home() / NODE_RELATIVE_PATH] + home_env = os.environ.get("HOME") + if home_env: + candidates.append(Path(home_env).expanduser() / NODE_RELATIVE_PATH) + candidates.extend(parent / NODE_RELATIVE_PATH for parent in SCRIPT_PATH.parents) + return candidates + + +def node_command() -> list[str]: + node = shutil.which("node") + if node: + return [node] + for fallback in bundled_node_candidates(): + if fallback.exists(): + return [str(fallback)] + pytest.skip("node is not installed") + + +def test_scripts_readme_mentions_selling_console() -> None: + readme = SCRIPTS_README_PATH.read_text(encoding="utf-8") + + assert "a2a/selling_console.py" in readme + assert "Selling pipeline console" in readme + + +def load_module(): + spec = importlib.util.spec_from_file_location("selling_console", SCRIPT_PATH) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class JsonTargetHandler(BaseHTTPRequestHandler): + response_status = 200 + response_body: dict[str, Any] = {"ok": True} + response_headers: dict[str, str] = {"Content-Type": "application/json"} + requests: list[dict[str, Any]] = [] + + def log_message(self, format: str, *args: object) -> None: + return None + + def do_GET(self) -> None: + self._record_request() + self._send_response() + + def do_POST(self) -> None: + self._record_request() + self._send_response() + + def _record_request(self) -> None: + raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0") or "0")) + self.__class__.requests.append( + { + "method": self.command, + "path": self.path, + "headers": dict(self.headers.items()), + "body": raw_body.decode("utf-8") if raw_body else "", + } + ) + + def _send_response(self) -> None: + body = json.dumps(self.__class__.response_body).encode("utf-8") + self.send_response(self.__class__.response_status) + for name, value in self.__class__.response_headers.items(): + self.send_header(name, value) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +class SseTargetHandler(BaseHTTPRequestHandler): + requests: list[dict[str, Any]] = [] + + def log_message(self, format: str, *args: object) -> None: + return None + + def do_POST(self) -> None: + raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0") or "0")) + self.__class__.requests.append( + { + "headers": dict(self.headers.items()), + "body": raw_body.decode("utf-8"), + } + ) + body = ( + b'data: {"jsonrpc":"2.0","result":{"id":"task-1","contextId":"ctx-1",' + b'"status":{"state":"TASK_STATE_WORKING"}}}\n\n' + ) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +@contextmanager +def serve_handler(handler_cls: type[BaseHTTPRequestHandler]): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + host, port = server.server_address + yield f"http://{host}:{port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def start_console(console, *, default_cwd: str = "/workspace/demo"): + config = console.SellingConsoleConfig( + host="127.0.0.1", + port=0, + default_server_url="http://127.0.0.1:41299", + default_cwd=default_cwd, + ) + server = console.create_server(config) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address + + class RunningServer: + url = f"http://{host}:{port}" + + def close(self) -> None: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + return RunningServer() + + +def test_create_server_disables_address_reuse_on_windows(monkeypatch) -> None: + console = load_module() + monkeypatch.setattr(console.sys, "platform", "win32") + config = console.SellingConsoleConfig( + host="127.0.0.1", + port=0, + default_server_url="http://127.0.0.1:41299", + default_cwd="/workspace/demo", + ) + + server = console.create_server(config) + try: + assert server.allow_reuse_address is False + finally: + server.server_close() + + +def get_text(url: str) -> tuple[int, str, str]: + with urlopen(url, timeout=5) as response: + return response.status, response.headers.get("Content-Type", ""), response.read().decode("utf-8") + + +def get_json(url: str) -> tuple[int, dict[str, Any]]: + with urlopen(url, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + + +def get_json_error(url: str) -> tuple[int, dict[str, Any]]: + try: + with urlopen(url, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + try: + raw_body = exc.read() + finally: + exc.close() + return exc.code, json.loads(raw_body.decode("utf-8")) + + +def post_json(url: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + request = Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + + +def post_json_error(url: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + request = Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + try: + raw_body = exc.read() + finally: + exc.close() + return exc.code, json.loads(raw_body.decode("utf-8")) + + +def post_raw(url: str, body: dict[str, Any]) -> tuple[int, str, str]: + request = Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=5) as response: + return response.status, response.read().decode("utf-8"), response.headers.get("Content-Type", "") + + +def post_raw_response(url: str, body: dict[str, Any]) -> tuple[int, str, str]: + request = Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urlopen(request, timeout=5) as response: + return response.status, response.read().decode("utf-8"), response.headers.get("Content-Type", "") + except HTTPError as exc: + try: + raw_body = exc.read() + finally: + exc.close() + return exc.code, raw_body.decode("utf-8"), exc.headers.get("Content-Type", "") + except RemoteDisconnected as exc: + return 0, str(exc), "" + + +def raw_http_request(url: str, request_text: str) -> tuple[int, str]: + parsed = urlparse(url) + assert parsed.hostname is not None + assert parsed.port is not None + with socket.create_connection((parsed.hostname, parsed.port), timeout=5) as sock: + sock.settimeout(5) + sock.sendall(request_text.encode("ascii")) + chunks = [] + while True: + try: + chunk = sock.recv(65536) + except TimeoutError: + break + if not chunk: + break + chunks.append(chunk) + + response = b"".join(chunks).decode("utf-8", errors="replace") + status_line = response.splitlines()[0] if response else "" + status_parts = status_line.split() + status = int(status_parts[1]) if len(status_parts) >= 2 and status_parts[0].startswith("HTTP/") else 0 + _, _, body = response.partition("\r\n\r\n") + return status, body + + +def test_pipeline_state_route_requires_context_or_task() -> None: + console = load_module() + running = start_console(console) + try: + query = urlencode({"serverUrl": "http://127.0.0.1:41299"}) + with pytest.raises(HTTPError) as exc_info: + get_json(f"{running.url}/api/pipeline/state?{query}") + finally: + running.close() + + assert exc_info.value.code == 400 + try: + response_body = json.loads(exc_info.value.read().decode("utf-8")) + finally: + exc_info.value.close() + assert response_body == {"ok": False, "error": "contextId or taskId is required"} + + +def test_pipeline_state_route_proxies_query_parameters() -> None: + console = load_module() + JsonTargetHandler.requests = [] + JsonTargetHandler.response_body = {"snapshot": {"status": "working"}} + + with serve_handler(JsonTargetHandler) as target: + running = start_console(console) + try: + query = urlencode({"serverUrl": target, "contextId": "ctx-1", "taskId": "task-1", "afterSequence": "7"}) + status, body = get_json(f"{running.url}/api/pipeline/state?{query}") + finally: + running.close() + + assert status == 200 + assert body == {"snapshot": {"status": "working"}} + assert JsonTargetHandler.requests[0]["path"] == ( + "/iac-code/pipeline/state?contextId=ctx-1&taskId=task-1&afterSequence=7" + ) + + +def test_task_get_route_sends_get_task_jsonrpc() -> None: + console = load_module() + JsonTargetHandler.requests = [] + JsonTargetHandler.response_body = {"jsonrpc": "2.0", "result": {"id": "task-1"}} + + with serve_handler(JsonTargetHandler) as target: + running = start_console(console) + try: + query = urlencode({"serverUrl": target, "taskId": "task-1", "historyLength": "2"}) + status, body = get_json(f"{running.url}/api/task/get?{query}") + finally: + running.close() + + assert status == 200 + assert body == {"jsonrpc": "2.0", "result": {"id": "task-1"}} + payload = json.loads(JsonTargetHandler.requests[0]["body"]) + assert payload["method"] == "GetTask" + assert payload["params"] == {"id": "task-1", "historyLength": 2} + + +def test_task_cancel_route_sends_cancel_task_jsonrpc() -> None: + console = load_module() + JsonTargetHandler.requests = [] + JsonTargetHandler.response_body = {"jsonrpc": "2.0", "result": {"id": "task-1"}} + + with serve_handler(JsonTargetHandler) as target: + running = start_console(console) + try: + status, body = post_json(f"{running.url}/api/task/cancel", {"serverUrl": target, "taskId": "task-1"}) + finally: + running.close() + + assert status == 200 + assert body == {"jsonrpc": "2.0", "result": {"id": "task-1"}} + payload = json.loads(JsonTargetHandler.requests[0]["body"]) + assert payload["method"] == "CancelTask" + assert payload["params"] == {"id": "task-1"} + + +def test_message_stream_route_forwards_sse_and_cwd_metadata() -> None: + console = load_module() + SseTargetHandler.requests = [] + + with serve_handler(SseTargetHandler) as target: + running = start_console(console) + try: + status, text, content_type = post_raw( + f"{running.url}/api/message/stream", + {"serverUrl": target, "cwd": "/workspace/demo", "prompt": "部署一个静态网站"}, + ) + finally: + running.close() + + assert status == 200 + assert "event-stream" in content_type + assert "TASK_STATE_WORKING" in text + payload = json.loads(SseTargetHandler.requests[0]["body"]) + assert payload["method"] == "SendStreamingMessage" + assert payload["params"]["message"]["metadata"] == {"iac_code": {"cwd": "/workspace/demo"}} + + +def test_message_stream_route_surfaces_recoverable_task_id_from_jsonrpc_error() -> None: + console = load_module() + JsonTargetHandler.response_status = 200 + JsonTargetHandler.response_body = RECOVERABLE_JSONRPC_ERROR + JsonTargetHandler.response_headers = {"Content-Type": "application/json"} + JsonTargetHandler.requests = [] + + with serve_handler(JsonTargetHandler) as target: + running = start_console(console) + try: + status, text, content_type = post_raw( + f"{running.url}/api/message/stream", + {"serverUrl": target, "cwd": "/workspace/demo", "prompt": "部署一个静态网站"}, + ) + finally: + running.close() + + assert status == 200 + assert "event-stream" in content_type + assert "data: " in text + assert "Pipeline already running." in text + assert "task-owner" in text + + +def test_selling_console_web_extracts_delivery_task_aliases() -> None: + console = load_module() + app_js = (console.WEB_ROOT / "app.js").read_text(encoding="utf-8") + + assert '"deliveryTaskId"' in app_js + assert '"deliveryContextId"' in app_js + + +def test_message_stream_route_keeps_read_errors_in_sse_body(monkeypatch: pytest.MonkeyPatch) -> None: + console = load_module() + + class TimedOutSseStream: + status = 200 + + def __init__(self) -> None: + self._sent_first_event = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + return None + + def __iter__(self): + return self + + def __next__(self) -> bytes: + if self._sent_first_event: + raise TimeoutError("upstream timed out") + self._sent_first_event = True + return b'data: {"ok": true, "event": "first"}\n\n' + + def open_sse_stream(server_url: str, payload: dict[str, Any]) -> TimedOutSseStream: + assert server_url == "http://127.0.0.1:41299" + assert payload["method"] == "SendStreamingMessage" + return TimedOutSseStream() + + monkeypatch.setattr(console.a2a_debugger, "_open_sse_stream", open_sse_stream) + + running = start_console(console) + try: + status, text, content_type = post_raw( + f"{running.url}/api/message/stream", + { + "serverUrl": "http://127.0.0.1:41299", + "cwd": "/workspace/demo", + "prompt": "部署一个静态网站", + }, + ) + finally: + running.close() + + assert status == 200 + assert "event-stream" in content_type + assert 'data: {"ok": true, "event": "first"}' in text + assert '"ok": false' in text + assert "upstream timed out" in text + assert "HTTP/" not in text + assert "Content-Type:" not in text + + +def test_message_stream_route_reports_upstream_reset_before_headers(monkeypatch: pytest.MonkeyPatch) -> None: + console = load_module() + + def open_sse_stream(server_url: str, payload: dict[str, Any]): + assert server_url == "http://127.0.0.1:41299" + assert payload["method"] == "SendStreamingMessage" + raise ConnectionResetError("upstream reset before headers") + + monkeypatch.setattr(console.a2a_debugger, "_open_sse_stream", open_sse_stream) + + running = start_console(console) + try: + status, text, content_type = post_raw_response( + f"{running.url}/api/message/stream", + { + "serverUrl": "http://127.0.0.1:41299", + "cwd": "/workspace/demo", + "prompt": "部署一个静态网站", + }, + ) + finally: + running.close() + + assert status == 502 + assert "event-stream" in content_type + assert '"ok": false' in text + assert "upstream reset before headers" in text + + +def test_message_stream_route_keeps_upstream_reset_during_stream_in_sse_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + console = load_module() + + class ResettingSseStream: + status = 200 + + def __init__(self) -> None: + self._sent_first_event = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + return None + + def __iter__(self): + return self + + def __next__(self) -> bytes: + if self._sent_first_event: + raise ConnectionResetError("upstream reset during stream") + self._sent_first_event = True + return b'data: {"ok": true, "event": "first"}\n\n' + + def open_sse_stream(server_url: str, payload: dict[str, Any]) -> ResettingSseStream: + assert server_url == "http://127.0.0.1:41299" + assert payload["method"] == "SendStreamingMessage" + return ResettingSseStream() + + monkeypatch.setattr(console.a2a_debugger, "_open_sse_stream", open_sse_stream) + + running = start_console(console) + try: + status, text, content_type = post_raw( + f"{running.url}/api/message/stream", + { + "serverUrl": "http://127.0.0.1:41299", + "cwd": "/workspace/demo", + "prompt": "部署一个静态网站", + }, + ) + finally: + running.close() + + assert status == 200 + assert "event-stream" in content_type + assert 'data: {"ok": true, "event": "first"}' in text + assert '"ok": false' in text + assert "upstream reset during stream" in text + assert "HTTP/" not in text + assert "Content-Type:" not in text + + +def test_message_stream_route_does_not_rewrite_headers_after_client_write_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + console = load_module() + send_sse_error_calls: list[tuple[int, str]] = [] + write_attempts: list[bytes] = [] + + class OneLineSseStream: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + return None + + def __iter__(self): + yield b'data: {"ok": true, "event": "first"}\n\n' + + class BodyWriteFailingWriter: + def __init__(self, wrapped) -> None: + self._wrapped = wrapped + + def write(self, data: bytes) -> int: + write_attempts.append(data) + raise OSError("disk full-ish write failure") + + def __getattr__(self, name: str): + return getattr(self._wrapped, name) + + def open_sse_stream(server_url: str, payload: dict[str, Any]) -> OneLineSseStream: + assert server_url == "http://127.0.0.1:41299" + assert payload["method"] == "SendStreamingMessage" + return OneLineSseStream() + + original_end_headers = console.BaseHTTPRequestHandler.end_headers + + def end_headers(handler) -> None: + original_end_headers(handler) + if handler.path == "/api/message/stream": + handler.wfile = BodyWriteFailingWriter(handler.wfile) + + def send_sse_error(handler, status: int, message: str) -> None: + send_sse_error_calls.append((status, message)) + + monkeypatch.setattr(console.a2a_debugger, "_open_sse_stream", open_sse_stream) + monkeypatch.setattr(console.BaseHTTPRequestHandler, "end_headers", end_headers) + monkeypatch.setattr(console.a2a_debugger, "_send_sse_error", send_sse_error) + + running = start_console(console) + try: + status, _text, content_type = post_raw( + f"{running.url}/api/message/stream", + { + "serverUrl": "http://127.0.0.1:41299", + "cwd": "/workspace/demo", + "prompt": "部署一个静态网站", + }, + ) + finally: + running.close() + + assert status == 200 + assert "event-stream" in content_type + assert write_attempts == [b'data: {"ok": true, "event": "first"}\n\n'] + assert send_sse_error_calls == [] + + +def test_message_stream_route_does_not_rewrite_headers_when_sse_error_event_write_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + console = load_module() + send_json_calls: list[tuple[int, Any]] = [] + send_sse_error_calls: list[tuple[int, str]] = [] + write_attempts: list[bytes] = [] + + class TimedOutSseStream: + status = 200 + + def __init__(self) -> None: + self._sent_first_event = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + return None + + def __iter__(self): + return self + + def __next__(self) -> bytes: + if self._sent_first_event: + raise TimeoutError("upstream timed out") + self._sent_first_event = True + return b'data: {"ok": true, "event": "first"}\n\n' + + class ErrorEventWriteFailingWriter: + def __init__(self, wrapped) -> None: + self._wrapped = wrapped + + def write(self, data: bytes) -> int: + write_attempts.append(data) + if len(write_attempts) == 2: + raise OSError("cannot write error event") + return self._wrapped.write(data) + + def __getattr__(self, name: str): + return getattr(self._wrapped, name) + + def open_sse_stream(server_url: str, payload: dict[str, Any]) -> TimedOutSseStream: + assert server_url == "http://127.0.0.1:41299" + assert payload["method"] == "SendStreamingMessage" + return TimedOutSseStream() + + original_end_headers = console.BaseHTTPRequestHandler.end_headers + + def end_headers(handler) -> None: + original_end_headers(handler) + if handler.path == "/api/message/stream": + handler.wfile = ErrorEventWriteFailingWriter(handler.wfile) + + def send_json(handler, status: int, value: Any) -> None: + send_json_calls.append((status, value)) + + def send_sse_error(handler, status: int, message: str) -> None: + send_sse_error_calls.append((status, message)) + + monkeypatch.setattr(console.a2a_debugger, "_open_sse_stream", open_sse_stream) + monkeypatch.setattr(console.BaseHTTPRequestHandler, "end_headers", end_headers) + monkeypatch.setattr(console.a2a_debugger, "_send_json", send_json) + monkeypatch.setattr(console.a2a_debugger, "_send_sse_error", send_sse_error) + + running = start_console(console) + try: + status, text, content_type = post_raw( + f"{running.url}/api/message/stream", + { + "serverUrl": "http://127.0.0.1:41299", + "cwd": "/workspace/demo", + "prompt": "部署一个静态网站", + }, + ) + finally: + running.close() + + assert status == 200 + assert "event-stream" in content_type + assert text == 'data: {"ok": true, "event": "first"}\n\n' + assert [attempt.startswith(b"data: ") for attempt in write_attempts] == [True, True] + assert send_json_calls == [] + assert send_sse_error_calls == [] + + +def test_implemented_post_route_rejects_malformed_json() -> None: + console = load_module() + running = start_console(console) + try: + target = urlparse(running.url) + status, body = raw_http_request( + running.url, + "\r\n".join( + [ + "POST /api/task/cancel HTTP/1.1", + f"Host: {target.hostname}:{target.port}", + "Content-Type: application/json", + "Content-Length: 1", + "Connection: close", + "", + "{", + ] + ), + ) + finally: + running.close() + + assert status == 400 + response_body = json.loads(body) + assert response_body["ok"] is False + assert response_body["error"] == "Request body must be valid JSON" + + +def test_unimplemented_post_route_ignores_malformed_content_length() -> None: + console = load_module() + running = start_console(console) + try: + target = urlparse(running.url) + status, body = raw_http_request( + running.url, + "\r\n".join( + [ + "POST /api/not-found HTTP/1.1", + f"Host: {target.hostname}:{target.port}", + "Content-Type: application/json", + "Content-Length: nope", + "Connection: close", + "", + "", + ] + ), + ) + finally: + running.close() + + assert status == 404 + assert json.loads(body)["error"] == "Not found" + + +def test_parse_args_defaults_to_loopback_and_current_directory(monkeypatch, tmp_path: Path) -> None: + console = load_module() + monkeypatch.chdir(tmp_path) + + args = console.parse_args([]) + + assert args.host == "127.0.0.1" + assert args.port == 41980 + assert args.default_server_url == "http://127.0.0.1:41299" + assert args.default_cwd == str(tmp_path) + + +def test_script_help_exits_successfully() -> None: + result = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--help"], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "Run a local A2A selling pipeline console." in result.stdout + + +def test_index_route_serves_selling_console_html(tmp_path: Path) -> None: + console = load_module() + running = start_console(console, default_cwd=str(tmp_path)) + try: + status, content_type, html = get_text(running.url) + finally: + running.close() + + assert status == 200 + assert "text/html" in content_type + assert "阿里云" in html + assert "您的购买方案" in html + assert "window.SELLING_CONSOLE_DEFAULTS" in html + assert "http://127.0.0.1:41299" in html + assert str(tmp_path) in html + + +def test_index_html_escapes_defaults_json_for_script_context() -> None: + console = load_module() + html = console.render_index_html( + console.SellingConsoleConfig( + host="127.0.0.1", + port=41980, + default_server_url="http://127.0.0.1:41299", + default_cwd="", + ) + ) + + defaults_start = html.index("window.SELLING_CONSOLE_DEFAULTS = ") + script_end = html.index("", defaults_start) + defaults_assignment = html[defaults_start:script_end] + + assert "