From 417ef3669efa19469aed9f38f1e98f420de46ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 16 Jun 2026 17:03:28 +0800 Subject: [PATCH 01/59] fix: handle A2A pipeline cancel handoff --- scripts/a2a/debugger.md | 26 +++ scripts/a2a/debugger.py | 72 +++++-- src/iac_code/a2a/executor.py | 13 +- src/iac_code/a2a/pipeline_executor.py | 116 +++++++++++- tests/a2a/test_app.py | 106 ++++++++++- tests/a2a/test_pipeline_debugger_script.py | 210 ++++++++++++++++++++- 6 files changed, 525 insertions(+), 18 deletions(-) diff --git a/scripts/a2a/debugger.md b/scripts/a2a/debugger.md index c03b96df..81f29a2c 100644 --- a/scripts/a2a/debugger.md +++ b/scripts/a2a/debugger.md @@ -30,6 +30,32 @@ 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 the +server starts in directory `a` but the debugger starts in directory `b`, +`--default-cwd "$PWD"` expands to `b`; the request is rejected with +`Invalid A2A workspace metadata.` unless `b` is under an allowed root. + +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 diff --git a/scripts/a2a/debugger.py b/scripts/a2a/debugger.py index 8eb94386..ef7f2583 100644 --- a/scripts/a2a/debugger.py +++ b/scripts/a2a/debugger.py @@ -206,6 +206,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 +325,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 +358,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 +378,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 +401,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": {}, @@ -890,6 +913,11 @@ def render_index_html(config: DebuggerConfig) -> str: background: #fef2f2; } + .timeline-canceled { + border-color: #fecaca; + background: #fff1f2; + } + .pill { display: inline-flex; align-items: center; @@ -1726,6 +1754,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 +1805,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 +1849,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 +2637,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, " "), @@ -3340,7 +3386,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 +4068,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", diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 7a4821c3..f77f9d0f 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -85,6 +85,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 @@ -107,10 +116,10 @@ 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 Exception as exc: + await publish_initial_task_if_missing() if _is_retryable_executor_error(exc): await self._publish_status( event_queue, diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index 02665789..6cb71954 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -30,11 +30,13 @@ ) 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.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.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 @@ -1382,8 +1384,22 @@ 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) + if handoff_envelope is not None: + journal.append(handoff_envelope) 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 +1407,102 @@ 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, + ) + return translator.manual_event( + "pipeline_handoff_ready", + "pipeline", + status="canceled", + data={ + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "canceled", + "summary": summary, + "reason": reason, + }, + ) + + +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) diff --git a/tests/a2a/test_app.py b/tests/a2a/test_app.py index 0451f3bc..42ffea9b 100644 --- a/tests/a2a/test_app.py +++ b/tests/a2a/test_app.py @@ -37,6 +37,7 @@ from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events from iac_code.a2a.transports.dispatcher import create_runtime_components +from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import TextDeltaEvent, ToolResultEvent @@ -789,6 +790,104 @@ def test_streaming_v03_method_with_v10_header_returns_sse(monkeypatch, tmp_path) assert loop.prompts == ["hello mixed"] +def test_pipeline_streaming_starts_with_task_before_status_update(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + + class StreamingPipeline: + pipeline_name = "selling" + sidecar_status = None + + def __init__(self) -> None: + self.prompts: list[str] = [] + self.session = SimpleNamespace(session_dir=tmp_path / "pipeline-sidecar") + self.handoff_enabled = False + + async def run(self, prompt: str): + self.prompts.append(prompt) + yield PipelineEvent( + type=PipelineEventType.PIPELINE_STARTED, + step_id=None, + timestamp=1717821600.0, + data={"total_steps": 1, "step_names": ["intent_parsing"]}, + ) + yield TextDeltaEvent(text="pipeline streaming output") + + def should_switch_to_normal(self, data: dict) -> bool: # noqa: ARG002 + return False + + fake_pipeline = StreamingPipeline() + fake_runtime = SimpleNamespace(provider_manager=object(), tool_registry=object()) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: fake_runtime) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + + with TestClient(app) as client: + with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "选择一个已有vpc,创建一个vswitch"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + body = response.read().decode() + + assert response.status_code == 200 + assert "Agent should enqueue Task before TaskStatusUpdateEvent event" not in body + assert "pipeline streaming output" in body + assert fake_pipeline.prompts == ["选择一个已有vpc,创建一个vswitch"] + + +def test_pipeline_streaming_workspace_error_returns_failed_task_event(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + allowed = tmp_path / "allowed" + outside = tmp_path / "outside" + allowed.mkdir() + outside.mkdir() + monkeypatch.setenv("IACCODE_A2A_ALLOWED_CWDS", str(allowed)) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + + with TestClient(app) as client: + with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "选择一个已有vpc,创建一个vswitch"}], + "metadata": {"iac_code": {"cwd": str(outside)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + body = response.read().decode() + + assert response.status_code == 200 + assert "Agent should enqueue Task before TaskStatusUpdateEvent event" not in body + assert "workspace" in body.lower() + assert "failed" in body.lower() + + def test_follow_up_message_through_sdk_route_updates_existing_task(monkeypatch, tmp_path) -> None: class EchoAgentLoop: def __init__(self) -> None: @@ -1686,7 +1785,12 @@ async def test_cancel_input_required_pipeline_task_after_restart_marks_canceled( assert persistence.load_task("task-1").state == "canceled" snapshot = A2APipelineSnapshotStore(pipeline_dir).load() assert snapshot["status"] == "canceled" - assert A2APipelineJournal(pipeline_dir).read_all_repairing_tail()[-1]["eventType"] == "pipeline_canceled" + assert snapshot["normalHandoff"]["action"] == "switch_to_normal" + assert snapshot["normalHandoff"]["targetMode"] == "normal" + assert snapshot["normalHandoff"]["outcome"] == "canceled" + assert "Outcome: canceled" in snapshot["normalHandoff"]["summary"] + events = A2APipelineJournal(pipeline_dir).read_all_repairing_tail() + assert [event["eventType"] for event in events[-2:]] == ["pipeline_canceled", "pipeline_handoff_ready"] finally: await components.aclose() diff --git a/tests/a2a/test_pipeline_debugger_script.py b/tests/a2a/test_pipeline_debugger_script.py index 43336d9a..aa7549f0 100644 --- a/tests/a2a/test_pipeline_debugger_script.py +++ b/tests/a2a/test_pipeline_debugger_script.py @@ -1038,6 +1038,23 @@ def test_index_html_cancel_uses_active_task_id() -> None: assert expected in html +def test_index_html_fetches_pipeline_state_after_cancel() -> 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", + ) + ) + cancel_body = html.split("async function cancelTask()", 1)[1].split("async function streamMessage()", 1)[0] + + assert 'appendRawEvent("sse", {type: "cancel", body});' in cancel_body + assert "await fetchStateIfAvailable();" in cancel_body + + def test_index_html_yields_between_batched_sse_events() -> None: debugger = load_debugger_module() @@ -1076,7 +1093,7 @@ def test_index_html_omits_completed_pipeline_task_id_after_normal_handoff() -> N for expected in [ "normalHandoffReady", "function streamTaskIdForControls", - "state.normalHandoffReady && !controls.activeTaskId", + "state.normalHandoffReady || isTerminalPipelineTaskState(state.status)", 'state.activeTaskId = "";', 'return "";', "updateNormalHandoffState(envelope);", @@ -1084,6 +1101,71 @@ def test_index_html_omits_completed_pipeline_task_id_after_normal_handoff() -> N assert expected in html +def test_index_html_omits_terminal_pipeline_task_id_when_streaming_followup(tmp_path: Path) -> 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", + ) + ) + script = html[html.index("")] + + 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 +1272,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 +1442,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() From 75ef643c24de0282f2cae847a11a4ca60cc489f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 16 Jun 2026 17:11:04 +0800 Subject: [PATCH 02/59] docs: describe A2A pipeline cancel handoff fix --- .../20260616-a2a-pipeline-cancel-handoff.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/batch/20260616-a2a-pipeline-cancel-handoff.md diff --git a/docs/batch/20260616-a2a-pipeline-cancel-handoff.md b/docs/batch/20260616-a2a-pipeline-cancel-handoff.md new file mode 100644 index 00000000..a1b7bdf2 --- /dev/null +++ b/docs/batch/20260616-a2a-pipeline-cancel-handoff.md @@ -0,0 +1,56 @@ +# A2A Pipeline Cancel Handoff 修复说明 + +## 背景 + +本次修复围绕 A2A HTTP 服务、pipeline 执行器和 `scripts/a2a/debugger.py` 展开。问题来自两类场景: + +- A2A executor 在早期异常路径中先发送 `TaskStatusUpdateEvent`,但 A2A SDK 要求 agent 先 enqueue `Task`,导致真实错误被包装成 `Agent should enqueue Task before TaskStatusUpdateEvent event`。 +- selling pipeline 在等待用户选择时执行 cancel 后,预期应发布 `pipeline_canceled` 和 `pipeline_handoff_ready`,并进入 normal chat handoff;debugger 也需要能拉取和展示这些事件。 + +## 主要改动 + +### A2A executor + +- 在 `src/iac_code/a2a/executor.py` 中增加初始 Task 入队保护。 +- `execute()` 内部现在会在发送任何状态更新前确保已经 enqueue 初始 Task。 +- 即使 workspace metadata 校验失败等早期异常发生,也会先满足 A2A SDK 的事件顺序要求,然后再返回真实失败原因。 + +### Pipeline cancel handoff + +- 在 `src/iac_code/a2a/pipeline_executor.py` 中补齐等待输入阶段的 cancel 处理。 +- `cancel_waiting_input_task_from_sidecar()` 现在会写入 `pipeline_canceled` 事件。 +- 当 pipeline 配置允许 `canceled` 状态执行 `switch_to_normal` 时,会继续生成 `pipeline_handoff_ready` 事件。 +- handoff summary 会优先使用 sidecar pipeline session 的上下文快照,必要时回退到 A2A snapshot 中的步骤结论。 +- cancel 和 handoff 事件的 sequence 会基于当前 high-water mark 递增,保证 debugger 可以从 `afterSequence` 正确拉取增量事件。 + +### A2A debugger + +- 在 `scripts/a2a/debugger.py` 中,cancel 请求完成后会主动调用 `/api/pipeline/state` 拉取最新 pipeline 状态。 +- 识别 `pipeline_canceled` 并在 timeline 中展示。 +- pipeline task 进入 `canceled`、`failed`、`completed` 等终态后,后续 Stream 不再复用旧 pipeline task id,避免 normal chat 被错误绑定到已结束 task。 +- 修复 debug log replay/export 对 pipeline state response 的解析: + - 支持直接包含 `eventType` / `event_type` 的 pipeline event。 + - 支持从 `snapshots.jsonl` response 的 `events` 字段恢复 timeline。 + - 支持解析带 `{ "snapshot": ... }` 包装的 snapshot response。 + +### Debugger 文档 + +- 在 `scripts/a2a/debugger.md` 中补充 `--default-cwd` 的含义。 +- `--default-cwd` 会作为 A2A workspace metadata 传给 server,表示 agent 执行任务时使用的 workspace,不是 debugger 自身的启动目录。 +- 如果该目录不存在、不是目录,或者服务端策略不允许,会返回 `Invalid A2A workspace metadata.`。 + +## 测试覆盖 + +新增和更新的测试覆盖了以下行为: + +- A2A streaming 先返回初始 Task,再返回状态事件。 +- workspace metadata 错误会以任务失败形式返回,而不是触发 SDK 协议错误。 +- 等待输入阶段 cancel 后会产生 `pipeline_canceled` 和 `pipeline_handoff_ready`。 +- debugger cancel 后会主动拉取 pipeline state。 +- debugger 不会在 pipeline 终态后继续复用旧 task id。 +- debugger timeline 能展示 `pipeline_canceled`。 +- debug log replay 能恢复 cancel/handoff 事件和 normal handoff summary。 + +## 国际化说明 + +本次 cherry-pick 没有修改 `messages.po` 或生成的翻译文件,也没有遇到国际化冲突,因此不需要额外合并翻译词条。若后续修改新增可翻译字符串,应按项目流程执行 `make translate`。 From d469dbfa4057a927908d2b4eb721841888da10a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 18 Jun 2026 00:10:15 +0800 Subject: [PATCH 03/59] Add generated VSwitch templates --- .../1-create-vswitch-in-existing-vpc.yml | 56 +++++++++++++++++++ templates/1-vswitch-in-existing-vpc.yml | 46 +++++++++++++++ templates/1-vswitch-under-existing-vpc.yml | 48 ++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 templates/1-create-vswitch-in-existing-vpc.yml create mode 100644 templates/1-vswitch-in-existing-vpc.yml create mode 100644 templates/1-vswitch-under-existing-vpc.yml diff --git a/templates/1-create-vswitch-in-existing-vpc.yml b/templates/1-create-vswitch-in-existing-vpc.yml new file mode 100644 index 00000000..5e6d7fb2 --- /dev/null +++ b/templates/1-create-vswitch-in-existing-vpc.yml @@ -0,0 +1,56 @@ +ROSTemplateFormatVersion: '2015-09-01' +Description: 在已有VPC下创建VSwitch +Metadata: + ALIYUN::ROS::Interface: + ParameterGroups: + - Parameters: + - VpcId + - ZoneId + - VSwitchCidrBlock + Label: + default: 网络配置 +Parameters: + VpcId: + Type: String + Label: + en: VPC + zh-cn: 专有网络 + AssociationProperty: ALIYUN::ECS::VPC::VPCId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} + ZoneId: + Type: String + Label: + en: Zone ID + zh-cn: 可用区 + AssociationProperty: ALIYUN::ECS::ZoneId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} + VSwitchCidrBlock: + Type: String + Default: 10.0.1.0/24 + Label: + en: VSwitch CIDR Block + zh-cn: 交换机网段 + Description: + en: The CIDR block of the VSwitch. Must be a subnet of the VPC CIDR. + zh-cn: 交换机的网段,必须是VPC网段的子网。 +Resources: + VSwitch: + Type: ALIYUN::ECS::VSwitch + Properties: + VpcId: !Ref VpcId + ZoneId: !Ref ZoneId + CidrBlock: !Ref VSwitchCidrBlock + VSwitchName: app-vswitch +Outputs: + VSwitchId: + Label: + en: VSwitch ID + zh-cn: 交换机ID + Value: !GetAtt VSwitch.VSwitchId + VSwitchCidrBlock: + Label: + en: VSwitch CIDR Block + zh-cn: 交换机网段 + Value: !GetAtt VSwitch.CidrBlock diff --git a/templates/1-vswitch-in-existing-vpc.yml b/templates/1-vswitch-in-existing-vpc.yml new file mode 100644 index 00000000..3767aea8 --- /dev/null +++ b/templates/1-vswitch-in-existing-vpc.yml @@ -0,0 +1,46 @@ +ROSTemplateFormatVersion: '2015-09-01' +Description: 在已有 VPC 中新建 VSwitch +Metadata: + ALIYUN::ROS::Interface: + ParameterGroups: + - Parameters: + - VpcId + - ZoneId + Label: + default: 网络配置 +Parameters: + VpcId: + Type: String + Label: + en: VPC ID + zh-cn: 专有网络 + AssociationProperty: ALIYUN::ECS::VPC::VPCId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} + ZoneId: + Type: String + Label: + en: Zone ID + zh-cn: 可用区 + AssociationProperty: ALIYUN::ECS::ZoneId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} +Resources: + VSwitch: + Type: ALIYUN::ECS::VSwitch + Properties: + VpcId: !Ref VpcId + ZoneId: !Ref ZoneId + CidrBlock: 192.168.0.0/24 + VSwitchName: app-vswitch +Outputs: + VSwitchId: + Label: + en: VSwitch ID + zh-cn: 交换机 ID + Value: !GetAtt VSwitch.VSwitchId + CidrBlock: + Label: + en: VSwitch CIDR Block + zh-cn: 交换机网段 + Value: !GetAtt VSwitch.CidrBlock diff --git a/templates/1-vswitch-under-existing-vpc.yml b/templates/1-vswitch-under-existing-vpc.yml new file mode 100644 index 00000000..87eb566f --- /dev/null +++ b/templates/1-vswitch-under-existing-vpc.yml @@ -0,0 +1,48 @@ +ROSTemplateFormatVersion: '2015-09-01' +Description: 在已有 VPC 下新建一个 VSwitch +Metadata: + ALIYUN::ROS::Interface: + ParameterGroups: + - Parameters: + - VpcId + - ZoneId + Label: + default: 网络配置 +Parameters: + VpcId: + Type: String + Label: + en: VPC ID + zh-cn: 专有网络 + AssociationProperty: ALIYUN::ECS::VPC::VPCId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} + ZoneId: + Type: String + Label: + en: Zone ID + zh-cn: 可用区 + AssociationProperty: ALIYUN::ECS::ZoneId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} +Resources: + VSwitch: + Type: ALIYUN::ECS::VSwitch + Properties: + VpcId: !Ref VpcId + ZoneId: !Ref ZoneId + CidrBlock: 192.168.0.0/24 + VSwitchName: app-vswitch +Outputs: + VSwitchId: + Description: 新建交换机 ID + Value: !GetAtt VSwitch.VSwitchId + Label: + en: VSwitch ID + zh-cn: 交换机 ID + CidrBlock: + Description: 交换机网段 + Value: !GetAtt VSwitch.CidrBlock + Label: + en: CIDR Block + zh-cn: 交换机网段 From 4353e9662b5d171540aee9e4fed66bb1f10b243c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Thu, 18 Jun 2026 16:53:25 +0800 Subject: [PATCH 04/59] Handle pipeline rollback cleanup recovery Record rollback cleanup resources durably, resume cleanup through normal chat, surface cleanup progress in REPL/A2A/observability, and expose cleanup prompts in prompt snapshots. --- ...0618-pipeline-rollback-cleanup-recovery.md | 57 + ...-06-17-pipeline-rollback-cleanup-design.md | 300 ++++ scripts/a2a/e2e/README.md | 10 +- scripts/a2a/e2e/README.zh-CN.md | 9 +- scripts/a2a/e2e/run_recovery_scenarios.py | 752 +++++++++- src/iac_code/a2a/executor.py | 716 ++++++++- src/iac_code/a2a/pipeline_events.py | 24 +- src/iac_code/a2a/pipeline_executor.py | 66 +- src/iac_code/a2a/pipeline_recovery.py | 135 +- src/iac_code/a2a/pipeline_snapshot.py | 255 +++- src/iac_code/a2a/pipeline_stream.py | 52 +- src/iac_code/commands/prompt.py | 278 +++- .../i18n/locales/de/LC_MESSAGES/messages.po | 280 ++++ .../i18n/locales/es/LC_MESSAGES/messages.po | 272 ++++ .../i18n/locales/fr/LC_MESSAGES/messages.po | 275 ++++ .../i18n/locales/ja/LC_MESSAGES/messages.po | 242 +++ .../i18n/locales/pt/LC_MESSAGES/messages.po | 273 ++++ .../i18n/locales/zh/LC_MESSAGES/messages.po | 234 +++ src/iac_code/pipeline/engine/cleanup.py | 730 ++++++++++ src/iac_code/pipeline/engine/loader.py | 6 +- .../pipeline/engine/pipeline_runner.py | 100 +- src/iac_code/pipeline/engine/step_spec.py | 2 + .../pipeline/selling/hooks/deploying.py | 65 + src/iac_code/services/context_manager.py | 23 +- src/iac_code/services/session_index.py | 17 +- src/iac_code/services/session_storage.py | 44 + src/iac_code/tools/cloud/aliyun/aliyun_api.py | 21 + src/iac_code/tools/cloud/base_stack.py | 16 +- src/iac_code/types/stream_events.py | 17 + src/iac_code/ui/dialogs/resume_picker.py | 3 +- src/iac_code/ui/renderer.py | 5 +- src/iac_code/ui/repl.py | 811 ++++++++++- tests/a2a/test_executor.py | 106 ++ tests/a2a/test_executor_cleanup.py | 1188 +++++++++++++++ tests/a2a/test_pipeline_events.py | 74 +- tests/a2a/test_pipeline_executor.py | 91 ++ tests/a2a/test_pipeline_recovery.py | 102 ++ tests/a2a/test_pipeline_snapshot.py | 278 ++++ tests/a2a_e2e/test_run_recovery_scenarios.py | 852 +++++++++++ tests/agent/test_agent_loop_continue.py | 24 + tests/commands/test_prompt.py | 163 +++ tests/pipeline/engine/test_cleanup.py | 549 +++++++ tests/pipeline/engine/test_loader_hooks.py | 47 + .../engine/test_pipeline_runner_cleanup.py | 113 ++ .../selling/test_deploying_cleanup_hook.py | 206 +++ tests/services/test_context_manager.py | 64 + tests/services/test_session_index.py | 42 + tests/services/test_session_storage.py | 55 + tests/tools/cloud/aliyun/test_aliyun_api.py | 46 + tests/tools/cloud/test_base_stack.py | 32 +- tests/ui/dialogs/test_resume_picker.py | 3 + tests/ui/test_renderer_helpers.py | 21 + tests/ui/test_repl_integration.py | 373 +++++ tests/ui/test_repl_parallel_tabs_lifecycle.py | 91 ++ tests/ui/test_repl_pipeline_handoff.py | 1297 +++++++++++++++++ .../ui/test_repl_pipeline_sidecar_restore.py | 50 + tests/ui/test_repl_status.py | 3 + 57 files changed, 11850 insertions(+), 110 deletions(-) create mode 100644 docs/batch/20260618-pipeline-rollback-cleanup-recovery.md create mode 100644 docs/superpowers/specs/2026-06-17-pipeline-rollback-cleanup-design.md create mode 100644 src/iac_code/pipeline/engine/cleanup.py create mode 100644 tests/a2a/test_executor_cleanup.py create mode 100644 tests/pipeline/engine/test_cleanup.py create mode 100644 tests/pipeline/engine/test_loader_hooks.py create mode 100644 tests/pipeline/engine/test_pipeline_runner_cleanup.py create mode 100644 tests/pipeline/selling/test_deploying_cleanup_hook.py diff --git a/docs/batch/20260618-pipeline-rollback-cleanup-recovery.md b/docs/batch/20260618-pipeline-rollback-cleanup-recovery.md new file mode 100644 index 00000000..3827bedd --- /dev/null +++ b/docs/batch/20260618-pipeline-rollback-cleanup-recovery.md @@ -0,0 +1,57 @@ +# Pipeline 回滚残留清理恢复说明 + +## 背景 + +selling pipeline 的 step5 在部署阿里云 ROS 资源栈后,如果中途被打断或回滚阶段异常退出,可能出现 `deployment.stack_id` 尚未写入最终 deployment 状态,但云上 ROS Stack 已经创建成功的情况。原有回滚逻辑只依赖当前内存状态,进程崩溃、REPL 恢复、A2A cancel 或异常 handoff 后都可能遗漏这些残留资源,导致云资源泄漏。 + +本次改动将 step5 rollback 中发现的待清理 ROS Stack 记录为可恢复的 cleanup ledger,并在 pipeline 最终进入 normal chat 后,通过正常 agent loop 自动发起一轮隐藏清理 prompt。用户仍然可以取消清理,但系统会把待清理资源、清理状态和进度持久化,便于恢复和观测。 + +## 主要改动 + +### Cleanup ledger + +- 新增 `src/iac_code/pipeline/engine/cleanup.py`,负责记录 rollback cleanup resource、状态、进度、错误信息和 cleanup prompt。 +- ledger 持久化到 session 目录,避免只保存在内存中,进程崩溃后仍能恢复。 +- selling pipeline 的 deploying step hook 在 step5 rollback 相关路径中写入 cleanup resource,避免把 pipeline 框架和阿里云 ROS 细节硬编码绑定。 + +### Normal chat 自动清理 + +- pipeline 结束、异常、cancel 或恢复 handoff 到 normal chat 时,会检查 ledger 中仍需清理的资源。 +- 发现残留后向 agent loop 注入一条 cleanup prompt,让现有工具调用和对话循环执行清理,不新增单独的清理执行器。 +- cleanup prompt 会进入 session transcript,恢复会话后仍可追踪;REPL 渲染时隐藏 prompt 正文,只展示清理状态提示。 + +### REPL、A2A 和恢复 + +- REPL 恢复时会重放 cleanup ledger 的摘要,使用单行、带层级的清理事件展示,减少噪音。 +- A2A pipeline snapshot、stream 和 executor 支持 cleanup state,恢复 session 后可以继续感知清理状态。 +- cleanup 相关事件使用统一前缀和颜色语义区分等待、进行中、成功、失败等状态。 +- 清理失败或未完成的资源在后续恢复 normal chat 时仍会被识别,用户继续会话后可再次触发清理。 + +### 可观测和工具事件 + +- ROS `DeleteStack`、`GetStack` 以及通用阿里云 API 调用会把 stack 事件传递给 cleanup 观察逻辑。 +- cleanup 状态不只依赖 `DeleteStack` 提交成功,还会根据后续 stack 状态更新为 `DELETE_IN_PROGRESS`、`DELETE_COMPLETE`、`DELETE_FAILED` 等。 +- A2A 和 REPL 都能从同一份 cleanup state 中读取进度,避免多 session 订阅互相干扰。 + +### `/prompt` 导出 + +- `/prompt` 在 normal chat 阶段不会误用已经结束的 pipeline prompt context。 +- cleanup prompt 会在 `Cleanup Prompts` tab 中单独展示,便于诊断。 +- 如果 cleanup prompt 已经从 provider messages 中被移除,只有在能基于 `session.jsonl` 找到前后稳定锚点时,才会插回 Provider Messages 并标记 `cleanup prompt · 已移除`。 +- 如果找不到稳定锚点,则不会在 Provider Messages 中展示,避免误导真实发送顺序。 + +## 测试覆盖 + +新增和更新的测试覆盖了以下行为: + +- step hook 能生成 cleanup resource,pipeline runner 会持久化 rollback cleanup ledger。 +- normal chat handoff 会根据 ledger 注入 cleanup prompt。 +- REPL 恢复能显示 cleanup 摘要和清理事件。 +- A2A executor、pipeline snapshot、pipeline stream 和恢复逻辑能携带 cleanup state。 +- ROS Stack 删除状态会通过 `GetStack` 后续检查更新为完成或失败。 +- `/prompt` 能展示 cleanup prompt,并按稳定锚点决定是否插入 Provider Messages。 +- A2A e2e recovery scenario 覆盖 cleanup 相关恢复路径。 + +## 国际化说明 + +本次 cherry-pick 没有出现 `messages.po` 冲突。新增和调整的用户可见字符串均使用项目现有 `_()` 调用方式,避免 f-string 包裹 `_()`。如果后续分支中翻译文件发生冲突,需要保留双方词条并执行 `make translate` 重新生成。 diff --git a/docs/superpowers/specs/2026-06-17-pipeline-rollback-cleanup-design.md b/docs/superpowers/specs/2026-06-17-pipeline-rollback-cleanup-design.md new file mode 100644 index 00000000..277c7600 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-pipeline-rollback-cleanup-design.md @@ -0,0 +1,300 @@ +# Pipeline Rollback Cleanup Design + +## Summary + +When the selling pipeline reaches step 5 (`deploying`), it may create an Alibaba Cloud ROS Stack before the step finishes. If the user then rolls back, cancels, or interrupts the pipeline before the `deployment` conclusion is committed, the stack can remain in the cloud without a reliable cleanup record. + +This design records stack resources as soon as they are observed, marks only step 5 rollback-related resources as cleanup-required, and starts cleanup after the pipeline hands off to normal chat. Cleanup is executed by the normal AgentLoop, not by a custom cleanup executor. The cleanup prompt is stored in the normal transcript but is not rendered as a visible user prompt in the REPL. + +## Goals + +- Prevent ROS Stack leakage caused by step 5 rollback in the selling pipeline. +- Persist resource observations and cleanup requirements so crashes do not lose cleanup state. +- Keep pipeline engine generic by moving selling/ROS-specific interpretation into step hooks. +- Use the normal AgentLoop for cleanup, so the user can cancel or continue naturally. +- Let REPL and A2A surfaces show cleanup status without rendering the synthetic cleanup prompt as user input. +- Support both `ros_stack DeleteStack` and `aliyun_api DeleteStack` plus `GetStack` polling, since the model may choose either path. +- Support resume and concurrent A2A sessions without cross-session cleanup state collisions. + +## Non-Goals + +- Do not block rollback while synchronously deleting resources. +- Do not introduce a custom cleanup executor that directly calls cloud tools. +- Do not build a general cloud CMDB. +- Do not clean resources from normal successful deployments. +- Do not clean all failed or canceled stacks by default; first scope is only step 5 rollback leakage. + +## Architecture + +The feature is split into four small parts: + +1. Resource observation: cloud stack creation emits a resource-observed notification as soon as the stack id is known. +2. Step hooks: the current step hook converts resource notifications into generic descriptors and later decides which observed resources need cleanup after rollback. +3. Cleanup prompt injection: after pipeline handoff to normal chat, pending cleanup resources trigger a synthetic normal AgentLoop turn. +4. Cleanup observer: while the normal AgentLoop runs, a per-session observer listens to tool events and updates cleanup status. + +The engine owns persistence and lifecycle wiring. The selling `deploying` hook owns ROS-specific interpretation. + +## Persistent Ledger + +The cleanup ledger is stored under the pipeline sidecar and written atomically. It is the source of truth for resume, retries, and A2A state. + +Example: + +```yaml +observed_resources: + - id: ros-stack/stack-xxx + source_pipeline: selling + source_step: deploying + source_attempt: att_0005 + observed_at: 1781630000.0 + resource: + provider: ros + type: stack + id: stack-xxx + name: demo-stack + region_id: cn-hangzhou + cleanup_required: false + +cleanup_resources: + - id: ros-stack/stack-xxx + source_observed_id: ros-stack/stack-xxx + reason: rollback_from_deploying + status: pending + cleanup_attempts: 0 + cleanup_run_id: cleanup-0001 + resource: + provider: ros + type: stack + id: stack-xxx + name: demo-stack + region_id: cn-hangzhou + accepted_cleanup_sequences: + - kind: terminal_tool + tool: ros_stack + delete_action: DeleteStack + success_status: DELETE_COMPLETE + failure_status: DELETE_FAILED + - kind: async_api_polling + delete_tool: aliyun_api + delete_action: DeleteStack + status_tool: aliyun_api + status_action: GetStack + success_status: DELETE_COMPLETE + failure_status: DELETE_FAILED +``` + +Statuses: + +- `pending`: cleanup is required but no matching cleanup call has started. +- `running`: a matching cleanup call or polling sequence is in progress. +- `succeeded`: cleanup reached a terminal success state such as `DELETE_COMPLETE`. +- `failed`: cleanup reached a terminal failure state or the tool returned an error. +- `skipped`: the user explicitly chose to keep the resource. + +## Resource Observation + +Resource observation must happen before final tool result handling. Waiting for `ToolResultEvent` is too late because step 5 can be interrupted or crash while the stack tool is still polling. + +For ROS stack creation: + +1. `CreateStack` returns `stack_id`. +2. The tool emits a `ResourceObservedEvent` containing action, stack id, stack name, region id, tool use id, step id, and attempt id. +3. The current step hook receives the notification. +4. The hook returns an `ObservedResource` descriptor. +5. The engine persists that descriptor to the ledger. +6. The stack tool continues normal polling. + +If the process crashes after the cloud API succeeds but before local persistence, the stack can still be missed. A later enhancement can reduce this window by forcing stack names or tags to include session and attempt identifiers. That enhancement is outside the first implementation scope. + +## Step Hook Responsibilities + +The selling `deploying` hook gets two new optional hook points: + +```python +def on_resource_observed(event, context) -> ObservedResource | None: + ... + +def on_rollback_cleanup_required(context, ledger, rollback) -> list[CleanupResource]: + ... +``` + +`on_resource_observed` turns ROS stack creation notifications into generic observed resource descriptors. + +`on_rollback_cleanup_required` runs when a rollback leaves `deploying`. It selects only the observed resources created by the relevant `deploying` attempt and returns cleanup descriptors. This keeps the pipeline engine from hardcoding ROS details. + +The hook may also provide: + +```python +def render_cleanup_prompt(resources) -> str: + ... +``` + +If absent, the engine uses a generic prompt built from cleanup descriptors. + +## Cleanup Prompt Injection + +When the pipeline transitions to normal chat, the normal chat runtime checks the ledger. If any cleanup resource has `pending`, `running`, or `failed` status and is not `skipped`, the runtime injects a synthetic cleanup turn into the normal AgentLoop. + +Important behavior: + +- The cleanup prompt is stored in the normal transcript. +- The REPL does not render the prompt as a visible user message. +- The REPL renders a separate status line, for example: `Detected 1 leaked rollback resource; starting cleanup.` +- A2A publishes cleanup state events and includes cleanup state in snapshots. +- The cleanup turn uses normal AgentLoop tool execution, permissions, cancellation, and transcript behavior. + +Prompt requirements: + +- Ask the model to clean only the listed resources. +- Allow `ros_stack DeleteStack`. +- Allow `aliyun_api DeleteStack` followed by `aliyun_api GetStack` polling. +- Require terminal confirmation such as `DELETE_COMPLETE`. +- Forbid creating, updating, or deleting resources outside the list. + +## Cleanup Observer + +CleanupObserver is a per-session, per-AgentLoop listener. It does not execute tools and does not call `GetStack`. It only observes normal AgentLoop events and updates the ledger. + +Startup conditions: + +- Immediately after injecting a cleanup prompt. +- On `--resume` when the session ledger contains pending/running/failed cleanup resources. +- On A2A task/context resume with pending/running/failed cleanup resources. +- Before the next normal user turn if cleanup remains unresolved. + +The observer is scoped by cwd, session id, task id, context id, pipeline run id, and cleanup run id where available. It must not subscribe to a global event stream without scope filtering. + +Event handling: + +- `ToolUseEndEvent`: if tool input matches a cleanup delete operation, mark the resource `running` and record `tool_use_id`. +- `StackProgressEvent`: update latest stack status/progress when it can be attributed to a cleanup resource. +- `ToolResultEvent` from `ros_stack`: parse the result. Mark `succeeded` only on `is_success=true` and `status=DELETE_COMPLETE`; mark `failed` on error or `DELETE_FAILED`. +- `ToolResultEvent` from `aliyun_api DeleteStack`: mark delete request observed, but not succeeded. +- `ToolResultEvent` from `aliyun_api GetStack`: parse stack status. Mark `succeeded` on `DELETE_COMPLETE`, `failed` on `DELETE_FAILED`, otherwise keep `running`. + +The observer matches resource semantics rather than hardcoding one tool. The descriptor says which operation sequences are accepted. + +## REPL UX + +The synthetic cleanup prompt is not printed as user text. Instead, REPL displays cleanup state: + +- `Detected N leaked rollback resources; starting cleanup.` +- `Cleanup running: ` +- `Cleanup completed: ` +- `Cleanup failed: ` + +Tool calls and stack progress still render normally. + +Resume behavior: + +- `--resume` reads the ledger and display replay. +- The user can see prior cleanup status messages. +- If cleanup remains unresolved, the next normal chat turn triggers or continues cleanup. + +## A2A UX + +A2A publishes cleanup-specific metadata events scoped to the current task/context: + +- `cleanup_resources_detected` +- `cleanup_started` +- `cleanup_progress` +- `cleanup_completed` +- `cleanup_failed` + +Snapshot cleanup shape: + +```json +{ + "cleanup": { + "status": "running", + "pendingCount": 1, + "runningCount": 1, + "failedCount": 0, + "succeededCount": 0, + "resources": [ + { + "id": "ros-stack/stack-xxx", + "provider": "ros", + "type": "stack", + "resourceId": "stack-xxx", + "regionId": "cn-hangzhou", + "status": "running", + "latestStackStatus": "DELETE_IN_PROGRESS" + } + ] + } +} +``` + +Each event includes task id, context id, pipeline run id, cleanup run id, and resource id so concurrent A2A sessions do not collide. + +## Cancellation and Retry + +Users can cancel the cleanup turn because cleanup runs through the normal AgentLoop. + +If canceled: + +- The ledger remains `pending` or `running`. +- The next resume or normal turn rechecks the ledger. +- Cleanup is prompted again unless the user explicitly skips it. + +If cleanup fails: + +- Status becomes `failed`. +- A later cleanup prompt can retry. +- A simple "continue" means retry cleanup. +- Only an explicit "keep these resources" or "skip cleanup" marks resources `skipped`. + +If another session already deleted the stack: + +- A missing stack or already-deleted stack should be treated as successful cleanup when the cleanup path can confidently identify the target. + +## Concurrency + +CleanupObserver is not global. It is attached to the AgentLoop and session being observed. + +Ledger updates include enough scope to avoid cross-session writes: + +- cwd +- session id +- task id and context id for A2A +- pipeline run id when available +- cleanup run id +- cleanup resource id + +If two sessions happen to try deleting the same cloud stack, each session only updates its own ledger. Cloud deletion is treated idempotently where possible. + +## Observability + +Add observability events for: + +- resource observed +- cleanup resource required +- cleanup prompt injected +- cleanup started +- cleanup progress +- cleanup succeeded +- cleanup failed +- cleanup skipped + +Attributes should include pipeline name, session id, source step, source attempt, cleanup run id, provider, resource type, resource id, region, status, and error category when available. + +## Tests + +Unit tests: + +- `CreateStack` returns stack id and triggers early resource observation before final tool result. +- `deploying` hook converts resource observations into observed descriptors. +- step 5 rollback converts only matching observed resources into cleanup resources. +- normal successful deployment does not mark cleanup required. +- cleanup prompt is injected when pending cleanup exists. +- cleanup prompt is persisted in transcript but hidden in REPL rendering. +- CleanupObserver marks `ros_stack` `DELETE_COMPLETE` as succeeded. +- CleanupObserver marks `ros_stack` error or `DELETE_FAILED` as failed. +- CleanupObserver handles `aliyun_api DeleteStack` plus `GetStack DELETE_IN_PROGRESS` plus `DELETE_COMPLETE`. +- `--resume` starts observer/injection from ledger state. +- A2A events include scope and snapshot cleanup state. +- concurrent observers do not update each other's ledgers. + +No tests may call real Alibaba Cloud APIs. diff --git a/scripts/a2a/e2e/README.md b/scripts/a2a/e2e/README.md index 1e158d52..3f11208e 100644 --- a/scripts/a2a/e2e/README.md +++ b/scripts/a2a/e2e/README.md @@ -75,12 +75,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 @@ -101,6 +106,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 @@ -148,6 +155,7 @@ When stabilizing changes, run the smaller or more diagnostic cases first: 6. `normal-running` 7. `cancel-step1` through `cancel-step5` 8. `rollback-step1` through `rollback-step5` +9. `rollback-step5-cleanup`, then `rollback-step5-cleanup-recovery` ## Preflight diff --git a/scripts/a2a/e2e/README.zh-CN.md b/scripts/a2a/e2e/README.zh-CN.md index 76aa4727..a0fefb78 100644 --- a/scripts/a2a/e2e/README.zh-CN.md +++ b/scripts/a2a/e2e/README.zh-CN.md @@ -71,11 +71,15 @@ 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 产物后请再手工或通过后续流程删除它。 ## 每个场景覆盖什么 @@ -95,6 +99,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 能完成。 | ## 代表输入 @@ -141,6 +147,7 @@ rollback 场景会在 step3 发送: 6. `normal-running` 7. `cancel-step1` 到 `cancel-step5` 8. `rollback-step1` 到 `rollback-step5` +9. `rollback-step5-cleanup`,再跑 `rollback-step5-cleanup-recovery` ## Preflight diff --git a/scripts/a2a/e2e/run_recovery_scenarios.py b/scripts/a2a/e2e/run_recovery_scenarios.py index 52bea6b6..a890248c 100644 --- a/scripts/a2a/e2e/run_recovery_scenarios.py +++ b/scripts/a2a/e2e/run_recovery_scenarios.py @@ -26,6 +26,8 @@ from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +import yaml + 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 +78,21 @@ INTERVENING_ASK_ANSWER = "使用默认配置(可用区和网段自动规划),继续。" ROLLBACK_PROMPT = "回退到 intent_parsing,选择一个已有vpc,创建一个安全组" CONTINUE_PROMPT = "继续" +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"}) 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 @@ -573,6 +586,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 +603,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 +615,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) @@ -868,11 +890,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 +909,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=CONTINUE_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 +1142,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 +1177,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 +1225,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 +1421,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 +1438,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 +1568,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() @@ -1481,6 +2183,8 @@ def _validate_scenario_execution(args: argparse.Namespace, scenario: str) -> Non "normal-running", "ask-waiting", "selection-waiting", + "rollback-step5-cleanup", + "rollback-step5-cleanup-recovery", *_RUNNING_STEP_SCENARIOS, *_ROLLBACK_SCENARIOS, *_CANCEL_SCENARIOS, @@ -1491,6 +2195,8 @@ def _validate_scenario_execution(args: argparse.Namespace, scenario: str) -> Non "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/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index f77f9d0f..7459e875 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 @@ -19,9 +20,12 @@ 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.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, @@ -32,15 +36,27 @@ from iac_code.agent.message import Message as AgentMessage from iac_code.i18n import _ from iac_code.pipeline.config import RunMode, get_run_mode +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.services.agent_factory import AgentFactoryOptions, create_agent_runtime 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 +74,641 @@ 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 _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 _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 "cleanup_started" + if status == "in_progress": + return "cleanup_progress" + if status == "completed": + return "cleanup_completed" + if status == "failed": + return "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, @@ -297,7 +948,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, @@ -505,11 +1177,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 @@ -522,26 +1193,37 @@ 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_prompt = _cleanup_prompt_from_handoff(handoff) + cleanup_ledger_path = _cleanup_ledger_path_from_handoff(handoff) + 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/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index d206c967..f440c061 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", @@ -662,6 +675,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 +688,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} diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index 6cb71954..5ba6d634 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -41,6 +41,7 @@ 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 @@ -958,16 +959,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) @@ -1613,6 +1619,54 @@ 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() + 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 None + 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_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 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..9ba73eb1 100644 --- a/src/iac_code/a2a/pipeline_snapshot.py +++ b/src/iac_code/a2a/pipeline_snapshot.py @@ -14,15 +14,33 @@ sanitize_public_tool_output_data, ) from iac_code.a2a.pipeline_journal import to_json_safe +from iac_code.utils.public_errors import sanitize_public_text 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 = { + "cleanup_started": "started", + "cleanup_progress": "in_progress", + "cleanup_completed": "completed", + "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", +} class A2APipelineSnapshotStore: @@ -32,7 +50,7 @@ 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): @@ -71,7 +89,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 +127,21 @@ 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) + return sanitized + + class _PipelineSnapshotReducer: def __init__(self, existing_snapshot: dict[str, Any] | None = None) -> None: self._snapshot = _snapshot_from_existing(existing_snapshot) @@ -129,6 +162,7 @@ def __init__(self, existing_snapshot: dict[str, Any] | None = None) -> None: self._candidate_restart_keys: set[str] = set() self._handoff_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) @@ -179,6 +213,7 @@ def _hydrate_existing_snapshot(self, existing_snapshot: dict[str, Any] | None) - self._hydrate_candidate_restarts() self._hydrate_control_history("handoffHistory", self._handoff_history_keys) self._hydrate_stack_history() + self._hydrate_cleanup_history() def _hydrate_steps(self) -> None: valid_steps: list[dict[str, Any]] = [] @@ -336,6 +371,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 +410,16 @@ 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) step = self._upsert_step(event.get("step"), event) candidate = self._upsert_candidate(step, event.get("candidate"), event) @@ -396,6 +452,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 +470,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 +803,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 +966,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")), @@ -924,6 +1058,12 @@ def _empty_snapshot() -> dict[str, Any]: "byId": {}, "history": [], }, + "cleanup": { + "status": "none", + "resourceCount": 0, + "resources": [], + "history": [], + }, "normalHandoff": None, "pendingInput": None, "control": { @@ -938,6 +1078,32 @@ def _empty_snapshot() -> dict[str, Any]: } +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 +1135,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): @@ -998,6 +1182,58 @@ 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 + ] + 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 +1448,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..8c7fe3ea 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -38,6 +38,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 +47,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 +200,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 +208,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 +253,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: @@ -281,6 +291,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 +377,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 +409,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 @@ -433,14 +459,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))], ) diff --git a/src/iac_code/commands/prompt.py b/src/iac_code/commands/prompt.py index 9089dbcd..d2d54a79 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} @@ -4643,8 +4799,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, @@ -4652,6 +4808,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 @@ -4682,6 +4839,34 @@ 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") + if isinstance(message, str) and message: + return message + return json.dumps(error, ensure_ascii=False) + if isinstance(error, str) and error: + return error + return None + + def create_server(config: DebuggerConfig) -> ThreadingHTTPServer: class A2APipelineDebuggerHandler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: object) -> None: @@ -4727,6 +4912,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() @@ -4786,7 +4997,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 3f11208e..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 \ @@ -93,11 +98,16 @@ after you finish inspecting the run. 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. | @@ -143,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: @@ -151,11 +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` -9. `rollback-step5-cleanup`, then `rollback-step5-cleanup-recovery` +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 @@ -232,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 a0fefb78..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 \ @@ -86,11 +91,16 @@ provider、tool、真实云调用场景默认会被保护住。只有确认要 `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 证据。 | @@ -135,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 覆盖后的文本,才会回退到运行时渲染图片。 + ## 推荐执行顺序 稳定或回归时,建议从更小、更容易定位问题的场景开始: @@ -143,11 +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` -9. `rollback-step5-cleanup`,再跑 `rollback-step5-cleanup-recovery` +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 @@ -222,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 0000000000000000000000000000000000000000..92c5a9e15d939ae9f66b9cd0689e601ab8a1c39d GIT binary patch literal 35073 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 CKG&N7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a4fae552aea447b5fbecb6e021024ff3e9057211 GIT binary patch literal 12697 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2f9864a7bbd7bd1a36bdf2246edebb1dcebe4a30 GIT binary patch literal 8684 zcmeI2_gj)}-2bWWvb6Lrv&JT~7Y71euK_>2ZyAz9C2~Mh$Jkw4lEx!i%im@1XS{lK`qD|udl!#A zsyciA-_MW#IqBLvcQxu-_o*w@uC_a7_wT)x&B_=%?g*`HBqTF*RtomI51G{JEp&-z!%G2r~(7h-B2!TM? zWZ!UuK>uF4&jA9Rj{kZB1d4q$bPNRgdEdbYWp%prN2MOHi;FpqH%e7Sg zyDyd}36KGMVZpml2aNPlL8ozM9yeEJxqwE%V$#gc#p?X?E+waGH?*I2PwMXQUJbAR zqj9WV&ouEvLf^!P>)+~k)^!?S>^3@`uG{?ffxd}l1);&IAY-KFY1ewb`+#kT=z@EB zlEB%wgRH2-P*O#vtg}V!;MM%k4Piw01PPP}_9STGk+_zzxw(0?Rz$ytlFAQf=2#;d z-z9$-`ltXbW7S*nlvrHsRoZ+|3`ZjEQ0CIRhX zq25KZWtm|Mf5j})JFbnDQx>^#3Rq|CR+y+IYFHJpAGp(wrVePg4iTx#3RQ;ZP zVN#%$MR}asX_AAXbSa~~nxE9>o&e?Te5K7)rRo(7x3zuiz#8{+#8fyWefns9^CHSl zMgB?sz<`N>v7Lc|SMxw%KyXHj8g$`Tr`!ycI!0SEN=^#7MEfk((=}r#Eyx3d5qL2N zjE5)dJSp6S4Rh4T1guTD&y^Wd*>#E>JHo-yQ8sD9d-RTiloW)u<7+RMrSl-AP-9r3 zxmi2oTUR^LI7N(u_plx&3?6H;9c9cOad+`YMY2XS+=E zlEn}%tXEBMk~3|pVt*Ar7cO>&J|147%QUm^rxae6whBS$p>M2R0d4yHldHwz`7dR( z7W|+u{_i-oSmPnXnI>=1=-Sq3b@QTEy0oYwZ6wP%U&dlJnHo;NB3K)l6cd9xAjSUr zh1Xe^WjT1hJb}io9QWV&%$j^l6|8ywn%oLLmVW8dj)#84FnT7`TiiI9Lx)St?Z7>8jG$i2$FG7#op>KuF*LN(q&dfXN*T|JT!Vg`0b4Y90Q?G?`rn8x*MeEG0&;Ds5%jR%^xOem6Tb%F?z; zDa<3PtE;IG*zY>_jpxERQ24B%h$bBQ&H9C?=Ea!p&!5b*4qaXxjwtkfbKjfSQgePQP2)$gz@7M`RcGF#;x4K*sO z&x9a(I73JV?pkRP^a=*G`fG!OsXs~+@qsQI?V zErp*ykXC1oaJUQeV%4F9Lk%2eam4Vb9!^1_G9{U5XTM_W8Pvq9v@}Z{9a5F(sY}lW zL9ZYZ%Vkbj@GW@dfnr!&N_vV|5yZ7=Om+}^)?KgQafPafk|&=`ntjb*d(839OXrhe z7cQixn41Sij&>@%{W5vC$ejG=Pq8(EQ)f~%v#zkPP(nh2ti1UdF_xJ1wDil~HqEbD zyVwgJ_p+p;gQyX9Fse^&q)s)PXy3GO??9JA(az%f4e?+;FRG)ffm+xr93S`i+78qXj=wkBFHPG*t3($ca z+>7;}Zh)MH^MjN`&Q4b`e-=I6tLq-~zFx-+X3l9PNZ@zA@?HtjPbr)@pj?z#Hp

GyJ&$nb$nu* z{zGHf_9U@O=ZH?*-Hm+~(-Khvet;OVj$*gJSFlh-+llyBh>XkbR9N(7)Cg;Saz+Oi zh=C)+g3>uw^IjR-!y@XvaAII*26bu~zi+w-J_a(i8Oplrn4RKX}fmN5?cHx z=k;N@np)P|mwvHxz38*H8EI*wa|U#0 zhB1$8^~>+oPh>X|x@P*?+OE&MfA3IWQ4@wHar^Yy77#ElGgE}FlEUI%bpOfJ+h=D z%lt|H3!vXpoa%Nczu7|D3__hacpA(8-CM?|Uu-Ps$tntJz!{M~!TI_q$P52PJ*@R}6})c!hgtL5L`-wqw!EmKMftI8h0t0x z(*^I`yy*T-8{`7x-L{ODR^(1okzPRnt2|QGKMp?r4dD>bmLnB(=v;^6Ef~HFqytGz za(AOhl;CY>nNvl@wr-IeCDYAbZUvYJCLZ|J`OlPop7#aR5XtraO3meeoH2f(3nTe9 z$6Ew%fX$iZLfOi`J}x>l!Rs>(Zdf=R$ryVrBqRh>t66`0WLt_A2aiy?*vf(@;d|>| zqt_+9u&^-Z+zA4KF!N6n>3~?j9lqc8Kwrm$Kt76jmo8&9SsRQx0;ndghU3{cjCdG)S4P2#{^0$!9T zqP4KxM%_D%l(Va|Yo2k-$jFG&kiDU%CT@XL7D%Xr6%kelBO@-)=l}j)Cr@9qKnmS? z{=DUW0X-}-Vbu!oODU&WNqcxzsypMV(rWstjko#_$&>^TXq)VO4 zFyp3hkffe=1BBF8TT8d0jOVHJSk6AZ&0ltTWR`b-Gz(!=V>&cvQT>w!Mn>6+E(aJ|rr$gLMT+h%E=*|nKvPkQ7ua-lDOWZyF6*3TP?VZlpqDyMr|NMnnV@CMtA{)KY$Qw+ z#=_A)!%(I+VK&er!KNFpZ$gIOcH4=Ss-cC|nm(OC@ans2@B;zg@F`mnBLAg#hB7|Y z5DXr0CcJizPMr^;+9`9Y=$- zC9}q!*`CB(GH}L&F;|9g2@Foj$=SB1sZ<3pZ{?`p_Wdr!KlR-$zSvPC^5AeEFWPCO z6||V6`z@rP&3k0N-gl{ajG`iQ@#D#tC6{O~21mVobtXKNoUp=PxA|B1TQnP@Hzwd; z^t{V%+SK*OS#Vg(!LMFAKUMvR06dVrFa&Q&{}zpAg|sSW#9rhqX!|!z#;C|VBYMtK z&QHmpBc9ddfP5RO`lIR6Stb5YVYvetClRPh8>F(iUAyWk-!<}V^k$`f#jz|q>P!O3 zV{cAl@hk1_KfWYIA9@;#N7x``wA8Jph|7JkQL=NPOj%efKD0kuWqk|B4{B|>0tM`R z8oGQXNV0Qq5VkOMBxet+il84ZZhUEJp)ZYb@XXUzyYP5)7+M;T_Mmg^bKR)A+8oWO z$+mWVRlliQU~|T0#nVmxOJxPK4fc>2785XA{O9 z^L4WPPsDs?OBG9i){Wb7f0jG`MPOi#mdk;Emr1sWH2dJ?j0%d**b0V1p)V>2>!Km| zSi%$jvD|AUnM!-fzImWl6)oUdS$ns&+X5xaerPDfyiH(}f1eMPV0Txrcvjh=b zLQk5#B1zgrUv@lcY|L$k9zfNAFG6tXE`QCFu~DM*)BzkYg9@nn+zy?6@$!0bvwA>K zQ1V9l56j}J^5??qm|Io6wUMSt+A%MQ4{KU={pT`ZQJ?dqQcDIk!q>lxfl2*2Wkv}nfiZJ<$NLwCAHq~sWhfWF97giX50FxYr`90&=y z+MbR>^(R3gtMSR+V-W$J3&Znz=Q845=wT1?7^=2Ky+=XYv%){T(hcz0Dkg@8&dnpW zvT*8LWJy)}p6#QX>JoLv?^=O|Nm|}=KWeY(r>UaK#_e?ECnH}VWpZBA(mH&HpJD8` z6iYc`11wSsvu(ruNYR_OS4}=8VB09r{oUAKm7D*`AU8C%Bg0{|t)0~wEWAnM;W4jt zpT}(F=+ME5MCytEG0xuNOOYI(j0_BdgMzYjARoyggQ+>(r$B5ewtIp}*UqsdpO^b< zd$qMtfB8W`A5T1YLX!qqQTst1tLILz4$+1T0Q)+!W8vPvAEpO&8T}p~e z{*pFY=khyuncrDt!`M4h^R*CYLF2g^Q>;GM8i}-Mu{H8>h}(8^Mta-b-am-zTRfl3 zhg9umSuCd6C{%mO*N8NSW4Er3q*{G{xHZoB?>rprJ3lZ?_?3DA)J+0hY7m@Yc1n5-4CNY<~XlZ;QwQB-3!mo}kT=>zto_ib%3rp$f9&8-7!~^rVo2#oO%G`6V44lCeNxx4c zymq`S&yM+CwL#N+h@S77d!fDCOPCtM1k6H zVyB3&tqN=_&|)cf8lLfqGFwHgkGIZg*@p$Go0`&u z(~MC;g&;cPM_k7Jhkm4%us>fOPZjF{IL)7{6=VVErxetO682@SD4n3->9Re$T+iSy z2dP&8c(z3vY-qR(Ulg}ZBlMy>>)S=AgU|NQB@h1~PzWOgc$08_7d1+A!+~;r?T;g2lTJn@~>Z=C#jPz(n{-7Q=Jx#!TTZJ zL+b!*Dlrz!qHVYvF5Pm_4y42M^}7ZezXVrVBhn6AmK8iEcM*z9CnF|r*t>)hPF%gr zR1`=68owMR-$auV`N!XP>k?#;)(A1JsEUb-@IyV1=jPS%@h{_+`V#o32ip%txVBf1 z@_O?o{;AIaQsIFCHS_3hGo{|tLeevUz zVrL(z-e=1uYqTLb#@|tk?{a{e_F^_Uv+27jpsH%2%$D^>r%0Gk?Fp;se=bFk!vHTi zcj^H$1OJw1#T&Qvsu|~HS&^NbwMTAEo^3r2YKs~8kO~?1o0JBRe2v2S=oo%8t3Br- zwIT4VXljsyn;p3M9%n~n#|tmUaC37@a#Qa(56GdtrVjRt=Jk2jYme4S26Dn_)T9M; zfJPn9B+eI=pI390x4x_`kItfXsa3FjW|(< z^4Yf<=9GTV#3ZpJ5+{POi+&PP2(S%A8bKlvpe5NcI2_&I@W1~WL-u;fzmX|o>9T)R zzxw}v)%?1yOFlsYfDN9-FXkyiSNMpfjuSv+2~{5G?pKm)O+{N@UnfQnEv5aT zu~(HHEMk}CDGT5M#fX8kZwGga%Il0f`3hU0Y1qi(gK{pTytC<<-9MJQe|UuUjU0zI zyB}ttuhwM;UpRFC2vJD-YAmAx+Xz2kDpEC=esuRk1<_N@<}49-pe52AumthoVAB=; zXXPS_-g3Tq@C@V4bP6$?r}2DX&DzVNdSAPh)zu5`gJ-MLzNA4{=@?GFJSL41 z5F8A!bz^Nm|9qU$FHQ_ZQnDmcLnT5h&^$AdDl76oa4|L2;*5@SsGXT>8sc`x@bLX8 zCCwYsD(ikL=HftrzN@b8>gxJdU(XgZmY(St9vu)6Q2wMjCljIofs{00nI<{=qDak~ zBvs79o20_Rg^dY+KeLMRr20%`e7r#sfK{as2!H`&1La(gzViVf#atZO^qF}vPxIE^ z{{))N7Zz6nTeOMZV@mi0rtD&sFQGKf&>Fz0#N>h&5w(3RfFonVj?S%)MIzkM7owu( z<~js$w$3Q)t7lK4KHRcEj=6gg6z`;@r1J8n@?PKtw#X*4;r$K`4_2NLs?I!ayU1V< zgK1RP&;ar+@DRR?dt3AE%x7l>LGVqlGx-|Jcnam@@OSRqkskASI(J+a$bu$^FkiLd zo}MbB6?3<~G-;N@$~L(V_XNFN=t**Dif~(K@YLCu_F!>=K$^Jw`sJIS+4#<$9)zJO zp|m=KBQmlnKJ>t4zm^Xu!92n82@3FyjT{_~YE$LP;P6#7c7p{4Jw{q?U*aV99ikcO zK!149o~VXJr3|XIzKhmK_D=RJ`begYnU3#p3vosj6t)vxXER$_aL0&5Sw ze0}*XyO4oMbs%B^d^;kt35UZ)G-(|kL>Q)T%2-uDM3{^BrrlbZr>3Kj^iQ8mu?(%3 zJ3rSWTNdoD@qj`)-zW1)McIYFadRsf5XNW9_|zVrbcjW09l z72xk)VT2vM;}$VA9xE*NN;A*^P%bxB`_9Is8On=nr|kUc(?=5nlkpWgfKtT|BCx?; znIt(OKo>fYAp|=+I^W5WHkKrn-GEIxXk^2Xs;-5QLdci5CJSeJfEK{FA~+CAmtA~} zHYnm2;pnFIyLq9#eoZ2PUdo+fJXcDP1rn4sGQ4QV5czV(gV;`t?B9GX`j)N}v^cw> zxKu$reJ=ieQ#8A+{oR?BD_QEf)+Gd)qkPwrN{iAo>2!l4%f`mWQG_F5-yTT|G?pKH z4y@N?sD81Oc58jNp)3vEvd|qL-CszX)j5^%cb>N&`*y0luS7)khYb^Rr|9El=GNBE z_O|Te%E}+IZGY!;*BWLv&JlnN_Rr4$IbU4$0Cewlmfp$2Xt+~_i26wFv7vB$b`zoS znG+r`Uy~C2hVlca(~!TTYAIro!$|M+%be)Sgkg_}1B}tbsM6R zFZobCgr)MCU4T+#WF<>UUChf9W~`29-FX&L_kkqS!v1F5%9_d$j7R}~8ln5#&Mw=! zq{apr&kDQzo0&&w&X3YM>^iB>!Fm>hLV3B|=0Dz|B<{&pmuH;mhn3yhpipvA z|8jyhjZOi}xUahYAo4_k)mt>5Pw|C(665LzxF?d6u1qwb=DK6BR`UJilrccJ)@kmfchQx z)*I=+7e6qeIsf~NqKdNj-{%%Q_f)ax}OcvYk5PUK6ZnjMaXV zAg>BTesd--FBp7}9Z2*i+%X;rUtvx1ZHDGKvNMV^pM;Rme0P6EX4&%tEd%!knGexm z{7`gUoWPH%^=zY*h*W+Q(v3{7)fJw|w#mBFpN~lRrxS(Q+Xn{tHOvbS*8f)Lv!c@? z6;)M*vd5FLZ5a|JaKf}yRb$g4yPK#O{cZ1yD16)39v6H%zA*aBoxb^&C_x>oq@W>? zJK>y8OG}e4Q;5j9){(W*m{wX)kYZ`hx;%G;xP<&>tb>uUO1ZSEw5SNPJbW<7Zg&}y zn`@Ny`jw9ELp>qp?Y$X^<1GoxvQ1glrk2syn_aeVi30uo!^IFt;cfC?vE100xb3YS z;$z$};Wo#?_Z}&!sf&BZIk`r0F>#SpF^#P)Fxiv)EG#+A%|1yua+^p*56``S%P9!h znq$#1aOz;jcFrt3p!&ubgh}N{r1@L_KnrMb7SsBLN#)aPG~Vo2%|?zBj-+#jcCOcJ zG1@FNUxj{McRJ^}$t@~kobAAGLUwUo#Xv(NLH(c-ngJIT^sP{VI&tV>57V$GT6C*! zoB_@UfnZEoNeNa{L{;^ZuWn6Ud;Z7Ym_nIyMkAVWNpX?!%4@s(>e*- zLP7?}Jj)}g)hN*X>c4Pi39@s#8uf!e1w}=n?nQNV#f+mFeQbl_CaCN5+!QwZnA37m z@5#l+M(EKzt^1lF+nJL?id&ItQWdYi%gihF>*L2O9L9Pt60JMp8zvv3*D(C{7d`Jb zsm1r4!SEOJ91SX%5@>AQI^RAc1pGRVhoitlV(aWXn-8gLec`u<-JD1y(WnSdDf#N7 zUr0&wQ}oZrC0-IHn!uXYW^>KMzh*3Z<>G?_J9HZUM50q6?~lH}59vnz0L|$CMVOS; z*h~qx&dqgI2t|L!zO&ep0{2So%<7?c^z#>QYvc6J3B^P0)^Ih8n(56bnx;(uh)3=E9Sd)-0%5Z%PTVdrU?HfQ_)a*4aK{ID0c}Ys>iK%ctiojbE1dcn{98 z_rF%O+~H%@G&Q5+<0CrP(J?%Q4dLN0QVvo6lze}$?Ph)qohU#Yt)hz;DT;!Hg^peu zd*kJofuF>9-)091R93e_MDVOPOCR*+xzV6mYCMi0>dU(CX(LHqphpR}icH5w5OxtF z!}Qf%tv(ZIbGM#b<(EVDSU#;NDM`(E9}x%90N)Wdn19uHXx;L+U!RV(ZeVEO^ITiR zjCPT|h^Mk70oZKZ8tgXu5uu^gF)^f(-@De! z-B)b9si~8-ay34_m z!DM$a;qvVc&Yv~c1zOUhpn%4t-Dx}brn!Z8kE!p!Wb45}KP93`vEx~e{ccrfQzhVa z?J*3|n}wu4-X2e9lzwQvFHv@JfqBS)5ct|ehaE19Dk?fCKNBM^J6y!q1$(VD`P5F_ zV9AawChg4YEXxi)HB{AFR0yMNO*IT2{b<14sK15tAzG{}QI=H_hj_k`^bBV%anu(nty6aA=0t%nNXK&DrDTQ{ZjY82XPsSz4R7rA zm>f^QG?}%$$Fp{fjf%Qz^n^gdOIdeaA0}eWwBUT^ENTztv$-0r5Az(*dcul|idZ%E zBNLvo`Em7Y{>CcD#4E#1X1x8*Wif7I;Fw&>+wFmfz}U^LxtAAO#j4wz+s-rRrI6k?53X&G zQBUs(N;H~WF1jk}%Cy&(;C$N)N=i0{mp^m5s;Rp8=j1>m1(HRo%pL^~40w}}Pa0nZ z2MGR+sPX?cEo zga0dcCt--8a=ARN1uvw#F_q7dT~GIy+yGcnud+c{o@>7)Lz|z4mSvydeDLPZ#e|j0?qG8E z+f$zWHSoz|&g8wH_OivX!8s8&D{poku{hXVJ6P|9$PT=vCh6c@k_u=H3j>~K_E$^&hKd*q2 zo`g26cazsM_;qybhm;cQwHtbvFg+dBHf@D>yAIxw`M|&+y=LqEfh057y&g|GO<}fD zDUH>qV>ZzISJ(EIt||P1IsSe;4Or*{2konkt>U}k0I&I+Xr!|^xj0Ile4X5We`{W+ zB5Q<$h70Ta2@9*Zs3`8Qam)C!1o@=r?i=#xhH=-}MzuhtMS7HZJp5LD{Tz7s5CF;K zCmH2DbL^gT@(jSuiXICS;x%bB%tdc9K{&!zVz@apEA1jDSBjsPiq2*R7>z z!E$bHAK&4<;}kx-b(g+)vPC9x`b-lQe5~tDR&O(z8EiyKR_1bD_Q^lUgP^c%i2XA`%qb|7Gj)*XV@@i4a*8*_A7=|DG@%F zy>J3Pm+)$)7KE$QrPffbhUBbdMiu9&K$3^`r;e_jbd)J>PpI6_O$CtTg z?bgo2&**Tz;q`d&fpF;D>0jBs`Oz#CD5?{-nr6g!x$#*^jK1t#e#i6A^U@0Jsp^9H zwV??uY zph4i*MMT#YA;GNGscrf8;s#kWxZ($|9i`J+HZ&4aqT*7bcDC(C&DS+V0wtUMQPeNt zaC17#>d;eLjlyb{LzE*^XcUx`a-T%oj@_)r{do-S>sB*nEilvkHD zSC>55J=M|7S~WEurSdspj;?sU;=l7HRI{>OLon+@ANcp;0Zsx(g!SRgb9-h42ipbl z&1K-+sM9;ak(68Z^-8DjZ7ZdYBXI1W!DWJoGj}`j8LPF;-0g2#BpDqY9SzBJh8&Cn zb!oFtEZ<^{c)suv__0qPN0jM(R&-a6M#-uOXW^Wt)M2U<|ii!&hi=1Q> z<>jYklnQys(9`Fr}kRWr0CFE)euS(HEdvO`UAv01C9crz~c1?$0cjXJMY zwqBawo$d2RsiPla_lf2JJ*7rv2PYWWp`y$zi42ZY)+{X3B}XaJHrj-|;i6(cJ#Kj& zC$8?1PgV}{mJ`iL0NE)nRlz{CW?xE*H4KRE**~z4jgKB28t}Z@y6kg8zX|RXM0X?0Ytrd$Lz?vmy5)6%ug7s2%~e*~h3-7zaZYL0B{$4Jd3qq` zIOhoS1#CghuDnr$IldJ&ukTR4V%xDzjCG$oIlm% zkG8D+?bT=e9MM~<(c0HO0y}(Du2L~)d;rZA_zvu5WX^PZUPr@$obyXbBdMfswV5nJ z$uNLDuX!%-_x-H?+2g+d7K=gzq8*37iqieUF5tqU$|vmfE+e4~f6xD1k*7-eRY~TrNbyvL>UnPNcJpoc<*+9*8p>}eBT3=TI@F#d zMMDK@MMWW1O-aR4)=F80zw}?J3ndYvc1qmF1%Gay8c6-vc z9owC1$pGj!l*>dH5be23O8YI9$Kc}5pxAt@72!t6Z0@i3kppE+dbEp)iRl&N+U4_z zKI81dT>g`2@2`rH_m0*`oVYx;1>M2jU>Gf)a-Z=Kn?x^LLUtNwI68!Nv_;L$opyG1 zZoyT^=M>6gk4dY(R`0PHcl>C%d^0*qU#(*Fc-G2hWT$6o*&0S$oG&M&+CMrNW;YcI z39p5O-+1W>zpxotN+Rw9EN`TVd|qq$1o|EbB*v4->^aT37&TETNS9Ba&NpMbixAwdqf)XMEX;8!Vc6Jsq^C@9YLLNa z^OTSqvI|jZ;&>T!+TAp2SUFJY)MpF{7LP&4VCNfWVIifC9Z=2F(9l58^p-OED@ytm zGvtL10X91?z^`40ov#}lKK{uSVmRCJ=bj*@__1>oXKu^&=6Q8i$mGd@DawM|8Fr#X zq7`Sc)oB_V&q%W?n#a_&d)MVGzzNI9E~F$REt8&`2eIORm9D@kHGJeTj?c}>>GEE_ z0kiH5((}^Oc^=x>yJ;hW#CgNIr`vY<9jwVxZZWUZTPMradizKdO`!mS0KU-ylQRTu z78aI{h(sV`jq9&dSHBmNkod~9ZxmtCGPY3e;6bfSr|$f)WWbqv;>1<}`A)$@Li4aJ zGV{A;URdK6H)HJyg1A z_L(;t^U{akzPbLGq727Puxrz=SeIPu=jVLs*t_AWSq%2t{PGqcKR}kznjHzlW=%M( zVG1LcPWpBudslTwzls(uXYWvr+j#p(9FgAQ+&R#v)s|GJg9GcUT4AmW&@vF;+)tpB zhSGWxpO-mB=HQP0ZV%2v`=+_BBfC4B)dYovq;stQ$dh?`I-PYy<@v^v*6w*TaJ^_; zyZ^H6=-vwpHXut1JL|VQ;wIX^PN(S9(2~B@UCI5$40cni$2jchDX%1t&xaB@Vyy`6ot7<;eJzpkO#0H$KP(>5-k?)s;I{zH zYmDY=>WB_wA~+Nft%fbx@`(BbY$Ft3CAu_bT?Eb|?zRlkvQg1gOU-Ppa%%X>NGkMJ z-=X_~{^7&SGIdo|GJ1+8XYU|zlJ@M?D79Z7Rp&W2dk{oSDWC{B+|T;fef4;IG_9Km zP|$-re@X5<(XlnVwS^=l6@^waM~eR2w3aN=Y}cRIvxoOr(~_0d&}g(<6uw~skSa@d zWUT!ikmi=^2{@tohcJin8O+PKVITLL2tS4dYXf+wc;P#_U7RM0CUZd3iS`-4>YurV ziRyiZx5sKrghdyf^Cm}3T;9W0ke*;i8uSvfoy|h`?Z{2-DGzg}w^biplIYbt{3}*j zE&gy(BAssuKtbf!jcjhlJ2j;UKU!}-nPC}?@%r~Jo5N7aEbi1{i8`Z$3f{M!l7_~> zV3+djsG>Aj_gmu7u_J4<^4F~Ts^4o>M=$ne3PbZ<(%GTf4rBe`F3XNct%l53-cLQSLOBg zke{}iX#Uj5iw8(X)6>(j;8vaX$;(3q8d%Rl_>V7Oo?8G-XUb35fneH$L#0(;OLQ)} zsOTW$J+jN?+1P!^ScrfijmSIQKb{#RG&IrCID_G$1bzYZtaSnu(`akgi+cb<-5xJA z{=15x@?VB5E$le|ZP$4c9VUwI@5rGH4ykHAT^Nl~_qr05UUXH~cY z#|>*Jl}slFo*)93%}{?J;%Y2V`rdH2X6X~__*T&7LE~I@qlpu13iAoSvGY)%^z<~# zkviK~%;K_ql6sVodiFzjksk~9eC{<%9Z^}EAb`UqS>16QaAv7j{&M0{vKPX25~tbuV!^E+r(qO7?$`H-M~gA3 zS+^(K7#M;g(t3=EsHhvk!SH}pTwZ=OFi6x`rQQ<>I(!FoUa`S}+#FJv^uM&dqk}Cg zi`>Mz6>}Jam~`rl(r5b~IuRad`Mzr*KfWB#ZH6k@v8prAP8(LHdQ0sy z&DDP3_bO~=T0XJB-Ew*X%W3sxHB%2gj{cd$7@fK7#`fAmwwbWc^;7@=fUlHaE34;k zfU;B3+0@MF`8OFj!cyE9)HtOWg*jal%#}-t|iCNMM!AJE~)a z37hT3fhm!>Y@LK-$|nbtYn?@V34m;cjdr~-RLve(^^7-dlb(kYnstl5W7arf1X!;Q zduwmr&J2z84-XAdeWiw!6cs^;g{E)DK8_U|JyVGQ^y9L0_LBA&FzWLrZ{iRv>NgFUpQL zVjkHKE)A{b;Gw>ulY;BsIXLOlCgZBa{S-q2zmG4O-vgCf2q!GmOAuWTR4tY~4{Kim z*$!W11JCh^hxjMI)=7uSZlmSb5>`uOk2?in;Ut+w$pE?`dY$E+5%uPz<uPh4#L%t$e9ljxSv}qa$afdpTt7^)>b%$`ph}Amp^(@XE%G z&sYW<-LrkP^1CLdLy6P2<$`89@1~cxl`;|`k7WMj?nt7d)$-yd1-(=9n2sG!O5iO8ab{j7B;Zfh+F0)@S`<(K*PpqR=(M};V-gZnEKSXQqaqJgjWUA< zgD&kkmlx@O+hqV)@&VtgnyU zR5N^Ymq!}lUWTHn&>7okGz7~`X#?k$CcCR*V`JH_$o!vH;^MeE$R{SA^n6@frxd(q za@MIgM6$OfdY;PkIig3H*Rot#sST3ZpEtYJjB&lPE-G!7(!%7Z6ltoex92Xn2 zX!9czxZE$HVPVykalM8fcO{BHBB$S>KSQ@T#s5X|I_Hd1O!zKgeiKUN3wt$xP)-=p zX&2^sc5T0DLB($tpxNO3YYgh-g|QW;@%P!0Md>6{r9_xfD6af&!Rby`VJ5H5(_XpA zr?eF(j(5O#K&GK7MuAqHIbdsf4R^%ZlQCQT=PO8vjK`383a_f#u-Ba}gq#hiWFWuR zPBB~7_6TOe2rAz8k;77>TVSb=Pjg8qb5ogV-=0a(Pb%rolX9zC6~U##smevr zr@wk5nQq)M0Fxtga{pYrt<8-P_nG&mySNXq!>t(R;;=<|&m5bbxLRn(i?6G3v~+xO zuasGHNIY-DUC|hd;kFM`O%NCYYB*OVv&s-%NvS()JJzV5|1T%mmU)V~_OQ!0U z@Y%P%h@-k-TYM*wC-^Qh>4o)x$8Cu&DPXBAg8#C%$GeSajEbiCySx^(@*y?7QRxh7 zf`nn$B^!X*SAChhb#(K`?AF^!SjC}YS}!w_hX<}0yBTAy0{U8-c;fXA1-Hww27@7b6_r4nJsgGiU9dAMF{ zRG1T+Y5n;Vf5UY{k)MOl=U}sx@Tu9Zf`uek8{eYPq=b{xhm7p?`vL>-!&qy%jF}%s zEO5u2B%_BD7U7-6x^976!{$)6Xn{sZQquO7KT;w zorZngx?V5PDLX7oN<_+HzO~FnJ+z-xNn4nPS*E7?rLEF}cZw%-TKjnal5dA$?NHp} zEs6KzC@yiN=OqO5t4_pJ+FME6sz?G`ye{FfjN{&+c_6R{gelaEj0Ak!7Z~3-2$tG)3 zb#h&{usXoA0$7d{l^1bMt7v3D`}tgN2sg_QWmFqvc#Z%8PjwMXNOwiEXP1lG1>BD9 z8+0k<@iA_n&bOT0+?|mC<}I%>p+KiEq*C>2)DH1XCY06AW;$n`*%b5{?TBi%ejeMx z;==>|{hTKbt?tuR&CMH6g`L9=cK<9F>8WVZoBFqkOu$AKW(60^ka+fNk>_ z%X<6anCzp3L|8tXF=l4%^<94mVd4H;SGraYX@UG)JjMdPCU|Qg@o}E0{SyV?9#}34 zXvn8P6%$z>ZF84asjzQ9%k;g2Og2Qv1vIKTN?AV5o{EagDJsq!LN0lT_eWZEdXJM8 zV!Z0CY;1To19m8@*?>GYqhggSXTr!hK_?aNKA7Iu-;eUa@8BSbis@C<02sjM0%*ie z3QAg;t5(%(4s%u07q&LGTeKq|>d`S{x3JJtotkPrm*H=!v)}3fS;bP*-yOHg&USoc zz%`LJ$qjHz0|Uh|-#|cgUGK|#)DuS%>7MM-zT3Y|ozd58LK{YJbx2mEQC0mp?tD{y zZ)bEK4qK^ZCiQQ#j)UOp8aYdb(sDO^TJci7JPBtax#0HGsFuO@mL{)P2@uvwwvdm+ zsEsre*@8z|QUxXf+*)*1d&USrhwxg#Z9dRU>SwPO9C*CHqL_t6MF6 zT63#`RLp>#nx$7n%TE1R@t6aokGs}j;q1j@_J1t%MkJ)bE!z{AKi$w(mUuhrjjgojBIF}nw z9F{sb+NdZPzMNkL`R>wbJ>T#IX{ftl1$LS^9XFc??N@f^b(~`Pw)gdkhI|TY?pz27 z5jSRT^l8TPrXfpVNk*)xEaK_;bByvprp=|?EEAoH8uSBVIxC6i;6E{;kbbth2Q~rV zrTYiQ6uCJ9ZHhE^b#L*tUo$+UN@&_aJY)}=4bI2@c&L#-J~lSdkiA|1rrJhN7@qQT zrD5#nFsa3c-i8wI;ckC_At15~w?3Zj95*2oZjKdNyW*C+vX!ftGf6oKy*jAo&SLg& zw=BV@*=*dJ)?k^_9i9GB2tSgA_2ti=9QQH&?=fq%J=2lT?DtWre*PSS5z=r$1{91P zdQ}ie=}tn{jmO{hkT}HCl{a=ezIw2zUJr%Ik09I<6DWc}=F6uU_S#A8PfCGnTahjY zTvA-w6Ikv!eT#M$J6(X=feOCjb=P%U)-#`}da6JiO%bZ7B=_|zb?eDKn@tCHHWgZf z64;5!M^4#`iKgtWxuc(}gvd{ndC*X!>Fc%Q2P_}G$m*+?@8Mh0^ z{80?D1B*pgHpJaha|7SO|HT9vJrUY`o}=WPMlA`)f|ud$g6tAdNVk`ZumDaP*OS#c zWp%~=SEnZ$Z=^(liuvZrT`8i6(dsJ+KuLj*2Fy1sdggtD3^vChIAVG~C9@$640M24 zNb5N$Xt*d|25-w&qc1L1VT2^GUg_k;Nl^lj@tGa3%7Zq0iH5^|yv#?nvy>9K6=B^} z$GcZ3XP-X;^CN?roLrbZnqI4wn2c<`IN#G$t!Fx!H+e=q1M$(B`(&USP1r6@U*8aD zXKTk;-@Hz~0#yT~R}xh@tVmY1Z?A+Nsg79HGvfo5csuf}`MliM83z+0Y&H?Xj7Ad{ z+O)xTUk(Wl=JO2pLcvT+i3(V$(BwSqa1hdNvF%TLCo(y~ls}}VUpHT*P56$RApL@b zc56KvI8TixYp^S&x`?wtMhFzOENY>531eahu1LH zM2o40=)5c+0ZT1UFym$|??`9zo|A?ck_KgyO8iGY+t}Ddgiz$+CWE9=Lkj!t)Api? zJo(k>qPQ$SFA$j6uPT6~4eOg(oNdu+-`Ytxvxc>jND~|^KBl^=8f|p%TrHCUbT@oQ z!p60pq<_#D*iY7-n7z>={-tpKX-{Fruc}-o#w1+Ui_gNp~o&nX`ZEN6j5t+Ff3+`>)RiDkMVA>&@qA0 z_UYvcW)|L=dt}Nv;Bfpor4)(QY2@l?oabww*|yqZ{asn)I78&Pm&E(9cAcHXp{2IP zG>`Wa)KE;DReR~JK>YA-zhacVvU zn5@aADrEpSn>A+gOYws9;*w|Q7l2xBm zT~JTTMWHOIXt4Q^F3Dm^z&7sou-ah0p!slp%u?=^-tPNw&ALZi4?_uJDZ!O;@pSmx z`UW4M-EGBiT&E)+tMUc!f~$?GVU-6hj{Sc4m>%y=*YerKaqLufS_IN+e%^_Q)Rt!1VRd9LHz*V0(uH=tN@;1Y&OHYa^iPQ_9t`vib7ISN@{9i z8WIQ*dLBaGuo13kd?YdzlxG!`mE+@;F)`xNF)@q`c4Ok=wAqh`7Nf5t&>u*iaiqn_ zqe0GzV$pGSd}NTO-u~WD&2^{XlVA<=G{_#3c6EQKVXD@vP#JN-1p$s!{(LzTQq;oA zN^1JHGhl{;EBE389z^jb3tqe%jnx>M4&Il6bOnePh|6oLslkVIM~UWGeJH7j+YpF| zU`XP8gCE4j|sLf&PMHRG1fO&Fl(t$c|0 z*xLjmdeE;Q24q4EU&c3kj=`5>U~EOVm75m8v07Hz0H;mlwk4T8+W7GD=A%l}O4@Z( z?+<1J)F7+X=)4g%g@v<@!<2HBBf7w0+!Cefrk!Y`G6~F*E}d`P^V;_i6p+meYG9dl zV(}rmeYUDz+&6o%u~^OW4H$*cJ&wJ7C%L|DX9Zk!#c7t5j}|3LEg|>SGry>2e+U7H zEA?2rXjK94b+Ms2ln-p{2A6v>G6NvKp~J@tXJ>v$NKmV(Lo7jZX80nT;(x9E!RGvY zQ@E1Nd3`D}Z)l8T(%5#YDFP!l76T);1QqGRwy|*mL>5uDp~jU|6rLU|n0<)eITs04 zZO^c>P(3B_RDVXq=l(9s@A>_eS9w{=*26Pj;>8NM$SW-o9yb@mb$&Th)u9_;+)P{? z;Gl*H3q^O0pT7T!*=hQ&M>hyMD*@YElr%Ub=u`I}5c3(iyJ^BYa(zJEy}7=&_va8` zwh0z{d}ItCZ!FvM&4cEAaOJc)OniZUs7gzbIkd$ z3j})Sw_2g1F;P)5@$vG?hhy^EPCNF3E8f-anUeb;n_SYIa{B$s`}x6sF9Q$=$uE%w z3Tnrqbg|opmqY_5bql;>>s*VnXl#A@jPqfMDqnAUVkaHKeE&wrvZPr|eSOA}ObK+q z=(2HeaECgJ7n2Zc*mp3B?=*b#q3RJiwE)t>kvJM%>kooAz@RLmr1_vd`@f{on1S7!S85BDHW4ZTgbTyWgTyb#^fwc z`W>Yv5EU24sLous&(_(oS6Jp|oGy5bSNtTc1}tPN!iK8V?`|_Un#xesNHEv1(o3ny zH$Ou~o7eXP$^W)^I1&z@zwLlS@@W2bO%5N+X*l%_7^*7`tDg~iARctal^yjABqy%g z>rJU1f#K=APWKo7XiQ;uqwW+7begUGI^X`&76r8nFo~d`u;A?fwRLO;I0Ji9hq&%hW+qfxv5Ld&%47*}A?T zADI|NB$^+d)b3<8!@}VXJT|vqj1n$zP{w$0_D}_ZAQ?bMF;Zb#e5VPFPs(QP4v?BQ zqSxZFeJc6=_8tUXxjdeT+``pCUcUwpA8LWahJis7|9BzKpYLLczXjxShZ|!rB@I_T zzh@T0J~9PUrAUtez^l88KtY|Ad4JEh*xK2NNM!Hz-1SabYKCq3 z#z3{rZ+r)|WYnOkE<2mJcgejZ6+R8$j-(t^NoU*muGbfT5$A z=z_pZquUY=uVoJiGDxm2z| zMZ@$jI~ts@kp^xywsf&v8#~-C(QFW4*7wdi>uE6 zCMq^HI$pVacdY_wR+tMgK14#@WFpcjA}GMRy}NtQVq+#je%ZWO(abqT#>}M1ApPA5uPIOkHEJxZ3Ycxm7(A z{GZu!;0%Gt2HtI@PXlbGOrgI}xBUYlqMinjlhg2P;e4F$mxf0Q^OyffQ%zYdu z|BM&@h80KBWBo zdH+)7FD5 zJ!5qRR*!syj*f~&yNYUW#Ni5gmPnuRUP}i=nrF<0?;*c?t48qtclP=oat0{&{N)hG zC>a#gT`_;gksvHfTFSq%)$^Otw6Ee!18qv$xl zFdb(au?-Ljpnj8)=N1%E(y}E7(tSYO8Yz&Jcwx|=nxaePTxs?6wrhwVZE?V@JwM4x zEnjsZV&n|?{ttNYex2*EhRL?dr+T?PZKA}9i~z4iDx7dN_e< z7A!9Icyq$xzDL+jgG)fK06l5>`TzwrCWEq9O${F#>{zK%%AhQ3Xt0>#zym_lXTWAL zm%m(dV;lFUTq-j|tzB16)q|AGb@!OY*4MLt5zvs&?+HUlvk%=Hww`^CpTC!F^5w=E*RlW?H&-L1Cun zWA8G=FN^9Jh4jA+b6anX?= zH0&`e6H*J}wf}|3mYQvQtamy9CIi^Z)%ukDu_K_HmC^TLee(|>Y~b0^#L@jg`7btE zS;@Gu@rxY~$@eKdk-E4D1^VY;A81hw-(L)URT>$aG%DMnRh5^MGqo~fv3bJI!9TnC zIOp^yHc5}Mx6iRt?Z);i=Aq+D!g{DbFbpQchM!vKaIdC{oy?BtxZlL7n~%*-vLtb4 z*6}0jhNF-yr5}NG`Jh}hUxjl&aXuk9SJ zm&?aLBec_W^?3YOowDXDiT6(X>y6|q>xDH3fG_yv3knLRWR#nB58LaWO|~zDP*G8F z>o`51E%s2Vsr^A$TGKta8*d^;$L zb$>#)YO`BYtxc<~;U`uOsGMZ2dQ44^*M8IXp>w$={l-L< zM?}&}x{JFjJteL*V`}9~pfNa_${kuuvz6~9b&tbw-Zn0(&Si5j0cbh`tf02^ZeH(a zW)d#aqkbH-CYBk;LX*$NKH_7khrj^?j#rgLe3NDanR7C@D}9vVkaWAf8kZa4(eyJ4 z6mY<0cc{vl>N$3wv;PI?NCT}cZUHbZANCB?nZ3eChsA+L1TIYRq0&-T%d&z3^zuWX znZ5qjkwm=C6_=R*$W&!(`M4yAk^|flffrDoB>J_$K4!CY7>Z;fjEf8eB(N7}RTdF( zxmrMIIK1)Q`|t#FlPcP)b$Q)dOpIsU;?5Ucy?m!}+^m(F)!*yU!%8J>c5Dg7QCf$O zcT_9(kuZr81kB~9yuGFFv!9tW%Bm)%s(p9*YPu+&tzT!R)XR<&5+RWm)4)R>$tHV8f416Z<}9vm}Xxr-NX+y(Y~mhjk2)xGl7L6G`pY z%4z(?@R>~KvhMBtUNOLth^e-jRSZNbI_d>j%&g=X4DBtAOu%rrMIJ!?!WZ_Y#r?$@ z;8OQGc{L@=Di@J4Uy+oLUO9QMqLOWd7X`cfA-mG}mc_{nAZ!{}wxXh{np>QG?6H~k z`)JJ|WVglha%5L(jW~W%v&lYb>?|)P+GuG4XoJ$JSF0*Y*`K2Of$>j{a@MT)<`w=! zP`|jc#a-H85Ksl$8aG|qT4>{3XWrJfC$jy)(oY)hE~|#_03-U9yNCB4_i@(S9p?aT zII{uXyt`B_T{7|GX(I6XoevlnKvBU;s4PO9E(W>#y%~0K#;l@ZGYzQ}F1K-CvD{A| z{5sz|B_$7IUEq&RC?Mao?QhvmTvw}u<_tn~E>oUl<)V3atlxiDc1wl~0_^3(aT5^U z#l^${Gy)R$#pTED?rv$kbvlQ7Q0h|(d=?-8_1LFrunl!7R(7z^=~Q)7lauL%Nt2R> zu(?d@lafr897po&%YYY5nw!(=M#yFI@buov3J6<6HIY6YS)a)V#|CxdNATlmS&wJy z;$w33)m#m&_cnh&>T0)=OR#CVuD-g`^m2l_&vsV>N$)6VwJ9oY7sqRG(DyfY{}~(@ zh~PR6x{dxuBG(`DIz*L+JG@p!-5qc+q**X5`FzDmku^#-ERpX`g1g~1kvQo z6sM*sUMh1gY#8oITY}p>*ZE@v1;*5z-#=?I32K)ht zH3dzGq7G=qWnt6-SX2^f|IjEfZ3b^V>MS33cd^t;syv)mlZ&womjjNCW0-)B3Cdtc zYbIwIo}hom=XP$`I==u0)4ieDDsR*1$(=C___Y>qNnw2%oMrgblWV~8qc?l*N~sZ& z5V~pDN=l9<0NiBr)5O=Y?sV-ObbR5UxiNi#B1P^C%+uiB%|=IE{{RW{tfHc&GAnvK zB$|31BH#9$Nn?TVE0Yga*(+AHtG-fJR8&+pI(04g6EMxi70HFx+86)6^dbH<3zha$gCHZK3(sUv)w;+Q_!I=7xLa{BlS4;Jiy< zWG_I+z`(GGXvV6dK9tntK7QdK+KIogdv>C@2fiVHM3X#H)Vm$I*i4j{jXtYwq`86V z@oIm*OuCFNwFY<;K-Ni(NOj`l(Z=vI3Y0=sLHaSRg?6*~H{aGQh0A0bUtGqo9_b7YS19(lI&pXSN-F$--wMe_Z zTv{TnhqJvpx8cJYdVQ*b&m1emS;Rv=W+M|V!bCE&PVH;M!V9_1g@;3 zTM^itAYF4Vj|Do1`E&&DtU_weMzd2BtDl3EWCyh-Dno@a^v}JNia{6wS$)o#= zOKar{t9{+-K;4h%N*%07R$&V?=YOSLjmZ+wumP?e}(VQmlOyEmq#2;II=r2i&(JFcS4RAkF zR8=i0r34jV_$n(X#l^-JQ_|FQ#jJIlUD{efa9UNqI(Aq355^5`iiOB3AO35y_4Fj> z+_LDcMRaIU&W!l-zY}nvGG;^%^vPc}^&7~LrOU=Fv^PL;Hku}j`7JlnbkpSin5NHe zl9Jn98F2Cu{1F9dw-5G!n#iAWE;}@Vd`kZK9&j20kqAJ+5kz~&647687>PlM(tVc{ ziy&4E_uJAp3O4B`Gm5L>dl{}>j)g3{`=VVyYwlXhA^Q5v3klyW(RZWrb)7g1s0Ro7 zQ2s!_C0OlJ@zYv#P?S?slj$3nwir)@_mg<3S#^P;wbiS&w$wb-D3feTWU@0|L~7op z;oity#F)u3X=cHA1GTm~rS%rS_1*6V`~4Ds-tWET<**}LSQ*I9fxn(L$KEi4B6Qfw zTkqfL%(_F@>aDsFBxBzx7#{>JdXUX@!htmhqS^-fIUpZ6AJ5m`-xoVQ%_z4di8H*2 z;nTTJN@|UYYN}C{SR4CqE%4Syz=nMZh{p~Ni7i3yw^#FK2(VR_4yl(vYhpp02>AIG zDo4OkLib7)2LMoZobVTK&vE(c*vxx4D5zKK40)?_W>An-@WNcyjsHXY$buAS#nz<_ zU3y%cxstQm(cyj$-Xx%!PIDG_cT~JIT={@Z*vH8LbDCD^tEDSrB^-e9bJel2T~#Z# zLYnd!OxU6SQF=MHYk)nV142qLr5wK9uANDVSY!GVnnzr-_?w`)LuO+o0u?LdtZx5DI zI1dSMZ-qcJW0kH+xHT6Hm`qSlqrImoNR4vv1q}*#D=vQI!%t`+g6T2FlWJzHTxAUOHhJ(6us*e9o0elGB z9h&cI7FF+kh^WQ*m>9-);FZSCd>s}aoK!(p8wC15U=>7V_ux_)vI8yI$ zuT*u5JAQ0TN-%5VP-2^al#*k|-MFwkIkpsDuT-xm+WpDeC#C1N@40Ywafl9q=0xd$ zuBXqK_%?3<^}D>Frm$Kgm@A5H8R8Vcx-Lm4a0Pcgm(#?C9~~dzt%tU7zdy`B+$<4&1w> zFt^08FQ#lR{I<^GO(c<^I_QOkweNy0*inEcq)NnieRs>|+6)R^Uo*yH6Hmx^e@5&U z!*)5T=4d#&pAxrsaaATrRv&JA$B$IAPA)q7ztc4ZrcFZ|#&4vE^%aBNN_^T^twVPc z$tM1=QiFY61Qa){Qw}rRZ--in*2qF2QbgVJ{o-alC(u>)`aZomt;Os!0K7QS-7wgt zTS1(%G9Q1>TqiNUFUjB1!R#)~^X$0a-a7Bl+p7m+Tj9E|h+ncUw6wFk)@~e+VC9ophGuet=2nH1qz?&+sm-!j zP3J>pnitNSbab`foTWe-PN-gY&;0N-csiQJd0|2}7L-?4_ZCe2DMflW+M2`}cQh}& z`0_qQH~6rJ?{~_gYj)%j@aWcHrNkvcg9bD$s&&F&XYi}%#{t9NH{0ndro)cz?tw5H ztGR8bPzvte=zdXTq0&raswX72?)d4M;-_&i<_Svi|J+tTZWk<4Uurxm?oueh>Qs zN7|G84g5$4T?J&u&l6OYbaaC=omX*die~|KdBIKPZcIbkT)Yr2KUzSTj-zlCFDXWe zX%2GIcvGFz-vhwmqO%thGb`)Ye6k_n4oBj-)Xitz%DSqjw z^aM=~Rn8I*F^~lC?HWBfGoQk2!Ehs*aCuNuY3MfQ*QIQ8-qCTiVH1-Zc^sKNarlU{*o;JWNz_3Q?>F7!|EJ*Q7Uh5@~$dRmG1){P>rGlhbZ zR(z6tanIPtD(LPM8tpQ*ToGJsi z#(G-g#eL3jJ7X@dXcInlK<@!|*ua(X=%<-);KR^-$dA+j!Ab#15^3^RPbeegkYpt) z@!)ipO%6(}wjnbU2#bmWco-KGs*Sj7BI_%5p?c|YgO!_4qopMi6Vrcy5)E3!8!59A z0woCfeX!wjC$t(3J;dyYItGnXg&dI5_8qUI;_lqZ*zDq-QpPYVLl|in88vTxkSY#w zU&D{#8&!EMTVFp=f1ocX7h8S;u`_&{Rh`uHfd~uK%qA7of zh^Qw=y=s(@LySqcN?7zp)USfanh=wTjVLikR?<{(`_M6DaS1l@Y zgEbrXSb-P-VPHorM7s*B^Y$rd&cCyJ7y^oS=60H@CyAV-H?@b`+*0&-x3HA!3ll1% zo-GLVume~HeGA}O5_8ZZ1qH`r>j`#X`bk^E=`wf9+qj0!GQK8L3Lnnb6VXB}kuOzRW z)n{je2I@IBE*9_*o>#!FpR}+z^(YcA8a)}?{*n>85_fDVj0^Fi(sYqde44x_)?6aU7=!EyLEHt+u`J`f>nyUlIVx`r*tRfql` PfjDJ$*0jjj>E3?;wtKr@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cf8f0407003df84a490b1efd141abdc162034b1c GIT binary patch literal 9907 zcmeI2=Qo^f+xA^)B7)?S5ClmCQ9`0dzeRBAQAZcS=q1Xi!xc3NgXmqfjNV2YqePER z^g24D_cmtcIqvnof5Q9eUC(?tXRKv8Psgzz+rI6;ZN9!&SERbbc!!LPj7sUBcUoj* z*UrJ`+1uB^_1n_hMlv!EAEkG)I$mizbL2V?CfdRl%{{rUX+5mqp^KZubj~Ag`XUBk#F`A1&|ye|?2B$Z~GO>kLcoH@%>sP)=7rbH;`|XrS^I z-3~jT=Qn%U!vjItC%j*nJc~x|Batg19c)CK4 z9tTH9XXnkwrzJXB#a@yId3Iw>>!@{pY$N3i3XaIjv$}MvHr&z5JgKPo#6B1PkT+W6 z-VGxornZ1)UM7(}A)zWSM5-PJD$K;R)lA@agQ^!BV2?(Vk1$u%?hX^1f_6i8)q7aQ z$r)==QPIS10V3DT%vAp|%Z%@@FYG+y9$T||SHqTismdjhk>9$%w{fAS$;ix1hs6B0 zbD!KsY)+^X{+UOS%AedGE7I#p6g^wlBPA;b3Hg*>t}@SKmHauW?PMRT~!U}!19|Q zBEG@8amIOC7gVWmU2czemT~)@<*!6DSEWi7o6Xnxjago`+qZ3Y;8=s32`rD8nAT%P z(Bl)jRMuZJgZAaU32Qr5Q{gvc9|_v!W1Hlnf-=?8#H|-+3j**V-|8PSN_||25R>s8 zG+vpiEXWJ#K#oVJrEQzm{QMdc8xzx`OXzjhfxgYaUPYw-MP}LYBS(jWMZAzk+)j5&RL??%>oEi*B($4Wr37fE2 zN6IvQQLMgO#5~%(uDVx&i`r8uxJu=qvJ{w#qsW1(F9)iJT3UN&>xl;2>us{6psx7_s!uT2V~2}mf*wP?4`B_IYYKBrf!(j>OPC%mRL(1S|h zfm(JIoUyrN%Q^hLG_MG0*2|`yPVQjpvR#VHC4Dy|ThaW$4$r zw16c_)*lQCi*j3Wsj04h4S5mae%-*xnu#>>B|3Vjrlk3`R;oHuyI@Q?PeAAx{`tyV z+mpHHp{s1(biKI&(a&2z@(lS^&~>$Ms!%@mnbS=K_Gl*BlVx=)i2@hHMYhmOCC*fP z{ib;yoDN~Xd>8!Q^>L)*Y$H}y|eSUxs6*B`rqX^u0oHz}9B9sp) za@t|E9x$1#Z*x98*PM^_f6yBo8j@IGEMqo5E1Ree9mnMiTfV{(ynZEr`JLb3?aJg6 zNus2v9*N)D_L!FZ)!*u)+Q0 zQcdE9?D;|1Qx^8{aPme4YU+l+-8cI#s7>&!Qss^M-&=p`j1|h24Go?BH1in7g^@>qAMApedVF*A4USn#m;4= zp+UZcs2g()Y`5R~`L#CUqg2aY3i0wjl}$Dn(&VNkoHEzE5)~B+j(9Xp>O)iZLL61+ ztQS!}7Ft?=qW6557^Y(uRc7EPQi>+xHFPsHqjvpZtq0U26B6h+B4h5SQe|g22=(0ev17a|c<0VGY=kaW|<5{D`IDB=H&yoS} z>3Hu(3p>*7GO?BInWC2(-3@bGD}z%ArC${{ry@D_^b6!Bc>R|?E)Ed5(&>!Lc$Z@h#P@LnZ zTiCdmo8Oy8^^(oqj&NzO^Yh~=EaPYq`|VbYr@6hTlc$FVZZ2npv++A;%iwkOblMwP z7MO&k?<*@S0`a7@cM0=X9nweic+2xYL=&dX(ZY$v8N-yRt_XC9If}d4LPH%@utr6p)

&G2A30hD;GUfRj!p;W^*!G$knK+yQ4L+m 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 literal 0 HcmV?d00001 diff --git a/scripts/a2a/e2e/run_recovery_scenarios.py b/scripts/a2a/e2e/run_recovery_scenarios.py index a890248c..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 @@ -27,6 +30,7 @@ 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 @@ -78,6 +82,10 @@ 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( { @@ -88,6 +96,16 @@ } ) 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-", "安全组") @@ -115,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, @@ -127,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 @@ -137,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] = [] @@ -195,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", @@ -243,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) @@ -327,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, @@ -337,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, *, @@ -350,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, @@ -360,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() @@ -372,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, @@ -726,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] @@ -998,7 +1339,7 @@ def callback(h: ScenarioHarness) -> None: 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=CONTINUE_PROMPT, name="06-cleanup-after-restart", task_id="") + 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, @@ -2179,6 +2520,11 @@ 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", @@ -2190,6 +2536,11 @@ def _validate_scenario_execution(args: argparse.Namespace, scenario: str) -> Non *_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, diff --git a/scripts/repl/e2e/README.zh-CN.md b/scripts/repl/e2e/README.zh-CN.md index 5ea4cb7b..6ea799c1 100644 --- a/scripts/repl/e2e/README.zh-CN.md +++ b/scripts/repl/e2e/README.zh-CN.md @@ -38,6 +38,11 @@ uv run python scripts/repl/e2e/run_pipeline_scenarios.py \ | `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` 后继续到选择并完成 | @@ -56,6 +61,11 @@ uv run python scripts/repl/e2e/run_pipeline_scenarios.py \ - `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 证据。 @@ -80,6 +90,19 @@ observed stack;删除前会再次校验云端 StackName 必须等于 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`。 + ## 产物 默认写入 `/tmp/iac-code-repl-e2e-runs//--/`: @@ -109,7 +132,7 @@ uv run python scripts/repl/e2e/run_pipeline_scenarios.py \ - `--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 的输入;默认 `继续`。 +- `--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。 diff --git a/scripts/repl/e2e/run_pipeline_scenarios.py b/scripts/repl/e2e/run_pipeline_scenarios.py index a2f269d6..0d3c36e5 100644 --- a/scripts/repl/e2e/run_pipeline_scenarios.py +++ b/scripts/repl/e2e/run_pipeline_scenarios.py @@ -39,6 +39,15 @@ 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 = "我有个产品要上线" @@ -163,6 +172,10 @@ "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", @@ -438,6 +451,23 @@ def send(self, text: str, *, label: str = "send") -> None: } ) + 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 @@ -751,6 +781,10 @@ 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 @@ -810,6 +844,23 @@ def _suffix_after_sendline_text( 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) @@ -826,11 +877,31 @@ def _scenario_stack_name(run_dir: Path, scenario: str) -> str: return f"iac-e2e-{suffix[:12]}-{safe_scenario}"[:128] -def _stack_creating_prompt(text: str, run_dir: Path, scenario: str) -> str: +def _stack_name_constraint(run_dir: Path, scenario: str) -> str: stack_name = _scenario_stack_name(run_dir, scenario) - return ( - f"{text}。本次 CreateStack 的 params.StackName 必须精确等于 `{stack_name}`,禁止使用默认或自动生成 StackName。" - ) + 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: @@ -1692,6 +1763,105 @@ def _apply_acceptance_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) @@ -2012,6 +2182,159 @@ def callback(pty: ReplPty, checks: dict[str, bool]) -> None: 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) @@ -2326,6 +2649,11 @@ def callback(pty: ReplPty, checks: dict[str, bool]) -> None: "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, diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 7459e875..c4ada626 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -14,12 +14,19 @@ 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 @@ -34,6 +41,7 @@ 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.engine.cleanup import ( @@ -45,7 +53,9 @@ 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 @@ -754,8 +764,23 @@ async def publish_initial_task_if_missing() -> None: 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: @@ -769,6 +794,8 @@ async def publish_initial_task_if_missing() -> None: ) 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): @@ -800,7 +827,7 @@ async def publish_initial_task_if_missing() -> None: 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, @@ -814,11 +841,8 @@ async def publish_initial_task_if_missing() -> None: 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, @@ -836,7 +860,7 @@ async def publish_initial_task_if_missing() -> None: task_id=task_id, context_id=context_id, cwd=cwd, - prompt=prompt, + pipeline_input=pipeline_input, ) return if route_pipeline_handoff_to_normal: @@ -1158,6 +1182,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(f"Current model {model} does not support image input.") + + 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() 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_executor.py b/src/iac_code/a2a/pipeline_executor.py index 5ba6d634..78a2cf48 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -37,6 +37,7 @@ 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 @@ -159,8 +160,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: @@ -195,7 +201,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: @@ -270,6 +276,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, @@ -294,7 +301,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)) @@ -395,15 +402,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) @@ -445,7 +466,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 @@ -468,12 +489,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() @@ -540,9 +570,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 @@ -555,7 +587,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): @@ -568,7 +603,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) @@ -623,11 +658,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, @@ -760,6 +795,7 @@ def _select_stream( pipeline: Any, prompt: str, *, + pipeline_input: PipelineUserInput, publisher: PipelineA2AEventPublisher, task_id: str, context_id: str, @@ -770,7 +806,10 @@ def _select_stream( _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)) + return _SelectedPipelineStream( + pipeline=pipeline, + stream=pipeline.run(_pipeline_runner_input(pipeline_input)), + ) pending_ask = _pending_ask_input_from_sidecar( publisher, task_id=task_id, @@ -784,6 +823,7 @@ def _select_stream( publisher=publisher, pending_input=pending_ask, prompt=prompt, + pipeline_input=pipeline_input, ), ) pending_pause = _pending_pipeline_pause_input_from_sidecar( @@ -793,15 +833,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)) + return _SelectedPipelineStream( + pipeline=pipeline, + stream=pipeline.run(_pipeline_runner_input(pipeline_input)), + ) pending_ask = _pending_ask_input_from_sidecar( publisher, task_id=task_id, @@ -815,6 +863,7 @@ def _select_stream( publisher=publisher, pending_input=pending_ask, prompt=prompt, + pipeline_input=pipeline_input, ), ) pending_pause = _pending_pipeline_pause_input_from_sidecar( @@ -824,20 +873,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, @@ -994,7 +1052,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 @@ -1006,11 +1066,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", @@ -1028,10 +1089,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, @@ -1151,13 +1258,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.") @@ -1188,6 +1301,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() ): diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index 23a993e0..a64bb42b 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -237,11 +237,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 +497,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) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 9550dbe6..b8eee7ec 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -167,7 +167,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 +176,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 +185,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. @@ -765,8 +779,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 = "" 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/pipeline/engine/interrupt.py b/src/iac_code/pipeline/engine/interrupt.py index 3f15f903..9e5f46d1 100644 --- a/src/iac_code/pipeline/engine/interrupt.py +++ b/src/iac_code/pipeline/engine/interrupt.py @@ -10,6 +10,9 @@ from pathlib import Path from typing import Any, Callable, Literal +from iac_code.agent.message import ImageBlock +from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input + logger = logging.getLogger(__name__) # LLM judge calls typically take 2-8s, but in parallel-pipeline mode candidate @@ -72,15 +75,16 @@ def __init__( self._get_state = pipeline_state_getter self._pipeline_dir = pipeline_dir - async def judge(self, user_message: str) -> InterruptVerdict: + async def judge(self, user_message: str | PipelineUserInput) -> InterruptVerdict: """Judge a user message. Returns verdict. Defaults to 'continue' on failure.""" import time + pipeline_input = normalize_pipeline_user_input(user_message) started = time.monotonic() - logger.info("interrupt judge START: message=%r", user_message[:200]) + logger.info("interrupt judge START: message=%r", pipeline_input.display_text[:200]) try: verdict = await asyncio.wait_for( - self._call_judge_llm(user_message), + self._call_judge_llm(pipeline_input), timeout=JUDGE_TIMEOUT_SECONDS, ) logger.info( @@ -105,19 +109,32 @@ async def judge(self, user_message: str) -> InterruptVerdict: logger.warning("interrupt judge FAILED: %s", e, exc_info=True) return InterruptVerdict(action="continue", reason=f"judge failed: {type(e).__name__}: {e}") - async def _call_judge_llm(self, user_message: str) -> InterruptVerdict: + async def _call_judge_llm(self, user_message: str | PipelineUserInput) -> InterruptVerdict: """Make the actual LLM call and parse the response.""" + from iac_code.providers.base import ContentBlock as ProviderContentBlock from iac_code.providers.base import Message as ProviderMessage + pipeline_input = normalize_pipeline_user_input(user_message) state = self._get_state() system_prompt = self._build_judge_system_prompt(state) - user_prompt = self._build_judge_user_prompt(user_message, state) + user_prompt = self._build_judge_user_prompt(pipeline_input, state) + provider_content: str | list[ProviderContentBlock] + if pipeline_input.has_images and isinstance(pipeline_input.content, list): + provider_blocks = [ProviderContentBlock(type="text", text=user_prompt)] + for block in pipeline_input.content: + if isinstance(block, ImageBlock): + provider_blocks.append( + ProviderContentBlock(type="image", media_type=block.media_type, data=block.data) + ) + provider_content = provider_blocks + else: + provider_content = user_prompt max_attempts = 2 last_response_text = "" for attempt in range(max_attempts): response = await self._provider_manager.complete( - messages=[ProviderMessage.user(user_prompt)], + messages=[ProviderMessage(role="user", content=provider_content)], system=system_prompt, ) last_response_text = response.text @@ -187,8 +204,9 @@ def _default_judge_system_prompt(self) -> str: "输出严格的 JSON 格式,不要包含其他文字。" ) - def _build_judge_user_prompt(self, user_message: str, state: dict) -> str: + def _build_judge_user_prompt(self, user_message: str | PipelineUserInput, state: dict) -> str: """Build the user prompt with full pipeline context.""" + pipeline_input = normalize_pipeline_user_input(user_message) sections = [] # Pipeline structure @@ -229,7 +247,14 @@ def _build_judge_user_prompt(self, user_message: str, state: dict) -> str: sections.append("=== Sub-pipeline 可回滚步骤 ===\n" + "\n".join(lines)) # User message - sections.append(f"=== 用户新消息 ===\n{user_message}") + user_text = pipeline_input.display_text + if pipeline_input.has_images: + user_text = user_text if user_text.strip() else "[Image input]" + user_text += ( + "\n\n用户同时提供了图片输入。请检查图片内容,并在 reason 或 rollback_context " + "中写出与路由相关的图像信息。" + ) + sections.append(f"=== 用户新消息 ===\n{user_text}") # Available actions sections.append( diff --git a/src/iac_code/pipeline/engine/pipeline_runner.py b/src/iac_code/pipeline/engine/pipeline_runner.py index 7167c0a7..8b73bef5 100644 --- a/src/iac_code/pipeline/engine/pipeline_runner.py +++ b/src/iac_code/pipeline/engine/pipeline_runner.py @@ -25,6 +25,7 @@ from iac_code.pipeline.engine.loader import load_pipeline_dir from iac_code.pipeline.engine.observability import PipelineObservability from iac_code.pipeline.engine.public_errors import public_error, public_error_from_exception +from iac_code.pipeline.engine.resume_recovery import reconcile_resume_messages, user_message_already_in_resume from iac_code.pipeline.engine.session import PipelineIdentity, PipelineSession, RestoreResult from iac_code.pipeline.engine.state_machine import StateMachine from iac_code.pipeline.engine.step_executor import StepExecutor @@ -32,6 +33,11 @@ from iac_code.pipeline.engine.sub_pipeline_executor import SubPipelineExecutor from iac_code.pipeline.engine.types import StepResult, StepStatus from iac_code.pipeline.engine.ui_contract import PipelineStepType +from iac_code.pipeline.engine.user_input import ( + PipelineInputContent, + PipelineUserInput, + normalize_pipeline_user_input, +) from iac_code.types.stream_events import ResourceObservedEvent, StreamEvent from iac_code.utils.public_errors import sanitize_public_text @@ -39,6 +45,10 @@ _TERMINAL_SIDECAR_STATUSES = {"completed", "user_aborted", "failed", "discarded"} _CURRENT_STEP_USER_INPUT_KEY = "current_step_user_input" +_CURRENT_STEP_USER_INPUT_CONTENT_KEY = "current_step_user_input_content" +_CURRENT_STEP_RESUME_MESSAGES_KEY = "current_step_resume_messages" +_CURRENT_STEP_PRECOMPLETED_TOOLS_KEY = "current_step_precompleted_tools" +_PENDING_ASK_USER_QUESTION_RESUME_KEY = "pending_ask_user_question_resume" _PENDING_INPUT_KIND_KEY = "pending_input_kind" _PIPELINE_PAUSE_CONFIRMATION_KIND = "pipeline_pause_confirmation" _REAL_RESTORE_FAILURE_REASONS = { @@ -56,19 +66,21 @@ def _string_answer_value(value: Any) -> str: def _user_input_received_data( - user_input: str, + user_input: PipelineUserInput, *, ui_mode: str | None, selected_index: int | None, waiting_options: list[Any], ) -> dict[str, Any]: - data: dict[str, Any] = {"user_input_length": len(user_input)} + data: dict[str, Any] = {"user_input_length": len(user_input.display_text)} + if user_input.has_images: + data["has_images"] = True if ui_mode != "candidate_selection": return data data.update( { "kind": "candidate_selection", - "selected_value": user_input, + "selected_value": user_input.display_text, } ) if selected_index is not None: @@ -80,10 +92,85 @@ def _user_input_received_data( return data -def _pipeline_pause_input_received_data(user_input: str) -> dict[str, Any]: - return { +def _pipeline_pause_input_received_data(user_input: PipelineUserInput) -> dict[str, Any]: + data: dict[str, Any] = { "kind": _PIPELINE_PAUSE_CONFIRMATION_KIND, - "user_input_length": len(user_input), + "user_input_length": len(user_input.display_text), + } + if user_input.has_images: + data["has_images"] = True + return data + + +def _serialize_pipeline_input_content(content: PipelineInputContent) -> str | list[dict[str, Any]]: + dumped = Message(role="user", content=content).to_dict()["content"] + return cast(str | list[dict[str, Any]], dumped) + + +def _deserialize_pipeline_input_content(value: Any) -> PipelineInputContent | None: + if isinstance(value, str): + return value + if not isinstance(value, list): + return None + try: + content = Message(role="user", content=value).content + except Exception: + return None + return content if isinstance(content, list) else None + + +def _serialize_pipeline_messages(messages: list[Message]) -> list[dict[str, Any]]: + return [message.to_dict() for message in messages] + + +def _deserialize_pipeline_messages(value: Any) -> list[Message] | None: + if not isinstance(value, list): + return None + messages: list[Message] = [] + try: + for item in value: + if not isinstance(item, dict): + return None + messages.append(Message.from_dict(item)) + except Exception: + return None + return messages + + +def _deserialize_precompleted_tools(value: Any) -> dict[str, dict[str, Any]] | None: + if not isinstance(value, dict): + return None + tools: dict[str, dict[str, Any]] = {} + for name, payload in value.items(): + if isinstance(name, str) and isinstance(payload, dict): + tools[name] = dict(payload) + return tools + + +def _serialize_ask_user_question_resume_state( + *, + user_message: PipelineInputContent, + resume_messages: list[Message] | None, + precompleted_tools: dict[str, dict[str, Any]] | None, +) -> dict[str, Any]: + state: dict[str, Any] = {"user_message": _serialize_pipeline_input_content(user_message)} + if resume_messages is not None: + state["resume_messages"] = _serialize_pipeline_messages(resume_messages) + if precompleted_tools is not None: + state["precompleted_tools"] = precompleted_tools + return state + + +def _deserialize_ask_user_question_resume_state(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + user_message = _deserialize_pipeline_input_content(value.get("user_message")) + if user_message is None: + return None + return { + "user_message": user_message, + "resume_messages": _deserialize_pipeline_messages(value.get("resume_messages")), + "precompleted_tools": _deserialize_precompleted_tools(value.get("precompleted_tools")), } @@ -194,6 +281,7 @@ class RestartInfo: start_from_step: str | None preserved_conclusions: dict[str, Any] rollback_context: str | None = None + rollback_input: PipelineInputContent | None = None @dataclass @@ -248,7 +336,9 @@ def __init__( self._sidecar_status: str | None = None self._sidecar_restore_result: RestoreResult | None = None self._current_step_user_input: str | None = None - self._restored_current_step_user_input: str | None = None + self._restored_current_step_user_input: PipelineUserInput | None = None + self._restored_current_step_resume_messages: list[Message] | None = None + self._restored_current_step_precompleted_tools: dict[str, dict[str, Any]] | None = None self._last_applied_interrupt_verdict: InterruptVerdict | None = None self._waiting_input_started_at: dict[str, float] = {} self._waiting_input_options_by_step: dict[str, list[Any]] = {} @@ -308,7 +398,11 @@ def __init__( self._active_candidates: dict[int, Any] = {} self._pending_candidate_restarts: dict[int, RestartInfo] = {} self._rollback_context: str | None = None - self._restored_supplement: dict[str, str | None] | None = None + self._rollback_input: PipelineInputContent | None = None + self._current_step_user_input_content: PipelineInputContent | None = None + self._current_step_resume_messages: list[Message] | None = None + self._current_step_precompleted_tools: dict[str, dict[str, Any]] | None = None + self._restored_supplement: dict[str, Any] | None = None # Total candidate count for the currently-executing parallel sub-pipeline # step. 0 when no parallel step is in flight. Used by apply_hard_interrupt # to detect scope="all" with partial completion and escalate to parent @@ -569,9 +663,7 @@ def restore_from_sidecar_sync(self) -> RestoreResult: max_rollbacks=self._loaded.max_rollbacks, ) self._step_attempts = self._step_attempts_from_snapshot(result.state_machine_snapshot) - restored_user_input = result.state_machine_snapshot.get(_CURRENT_STEP_USER_INPUT_KEY) - self._restored_current_step_user_input = restored_user_input if isinstance(restored_user_input, str) else None - self._current_step_user_input = self._restored_current_step_user_input + self._restore_current_step_user_input_from_snapshot(result.state_machine_snapshot) self.context = PipelineContext.from_snapshot(result.context_snapshot, self._loaded.context_dependencies) if result.execution is not None: self._execution = dict(result.execution) @@ -600,15 +692,17 @@ async def restore_from_sidecar(self) -> RestoreResult: return self.restore_from_sidecar_sync() def continue_from_sidecar( - self, user_input: str | None = None + self, user_input: str | list[ContentBlock] | PipelineUserInput | None = None ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: if not user_input: return self._continue_from_current(resume_running_step=True) return self._continue_from_sidecar_with_input(user_input) async def _continue_from_sidecar_with_input( - self, user_input: str + self, user_input: str | list[ContentBlock] | PipelineUserInput ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: + pipeline_input = normalize_pipeline_user_input(user_input) + user_text = pipeline_input.display_text was_pause_confirmation = self.has_pending_pipeline_pause_confirmation() if was_pause_confirmation: self._set_pending_input_kind(None) @@ -618,34 +712,35 @@ async def _continue_from_sidecar_with_input( type=PipelineEventType.USER_INPUT_RECEIVED, step_id=step_id, timestamp=time.time(), - data=_pipeline_pause_input_received_data(user_input), + data=_pipeline_pause_input_received_data(pipeline_input), ) - if user_input.strip().lower() == "continue": + if user_text.strip().lower() == "continue": self.resume_agent_loops() async for event in self._continue_from_current(user_input=None, resume_running_step=True): yield event return try: - verdict = await self._interrupt_controller.judge(user_input) + judge_input: str | PipelineUserInput = pipeline_input if pipeline_input.has_images else user_text + verdict = await self._interrupt_controller.judge(judge_input) except Exception as exc: logger.warning("Interrupt judge failed during sidecar continuation: %s", exc, exc_info=True) verdict = self._apply_interrupt_judge_failure_policy( InterruptVerdict(action="continue", reason=f"judge failed: {exc}") ) - async for event in self._continue_after_sidecar_judgment_failure(verdict, user_input=user_input): + async for event in self._continue_after_sidecar_judgment_failure(verdict, user_input=pipeline_input): yield event return if self._is_judgment_error_verdict(verdict): verdict = self._apply_interrupt_judge_failure_policy(verdict) - async for event in self._continue_after_sidecar_judgment_failure(verdict, user_input=user_input): + async for event in self._continue_after_sidecar_judgment_failure(verdict, user_input=pipeline_input): yield event return if verdict.action == "supplement": self.resume_agent_loops() if self._current_step_is_parallel_sub_pipeline(): self._restored_supplement = { - "message": user_input, + "message": pipeline_input.content, "target": verdict.supplement_target, } try: @@ -654,11 +749,14 @@ async def _continue_from_sidecar_with_input( finally: self._restored_supplement = None return - async for event in self._continue_from_current(user_input=user_input, resume_running_step=True): + async for event in self._continue_from_current( + **self._continue_input_kwargs(pipeline_input), + resume_running_step=True, + ): yield event return if verdict.action == "hard_interrupt": - async for event in self._continue_after_sidecar_hard_interrupt(verdict): + async for event in self._continue_after_sidecar_hard_interrupt(verdict, source_input=pipeline_input): yield event return @@ -667,23 +765,29 @@ async def _continue_from_sidecar_with_input( yield event async def _continue_after_sidecar_judgment_failure( - self, verdict: InterruptVerdict, *, user_input: str + self, verdict: InterruptVerdict, *, user_input: PipelineUserInput ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: if verdict.paused: yield await self._save_and_emit_interrupt_pause(verdict) return if verdict.action == "hard_interrupt": - async for event in self._continue_after_sidecar_hard_interrupt(verdict): + async for event in self._continue_after_sidecar_hard_interrupt(verdict, source_input=user_input): yield event return self.resume_agent_loops() - async for event in self._continue_from_current(user_input=user_input, resume_running_step=True): + async for event in self._continue_from_current( + **self._continue_input_kwargs(user_input), + resume_running_step=True, + ): yield event async def _continue_after_sidecar_hard_interrupt( - self, verdict: InterruptVerdict + self, verdict: InterruptVerdict, *, source_input: PipelineUserInput | None = None ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: - parent_rollback = self.apply_hard_interrupt(verdict) + if source_input is not None and source_input.has_images: + parent_rollback = self.apply_hard_interrupt(verdict, source_input=source_input) + else: + parent_rollback = self.apply_hard_interrupt(verdict) if self.sidecar_status == "failed": current_step = getattr(self.state_machine, "current_step", None) yield PipelineEvent( @@ -796,10 +900,12 @@ def _persisted_candidate_restart_info(state: dict[str, Any]) -> RestartInfo | No preserved_conclusions = restart.get("preserved_conclusions") if not isinstance(preserved_conclusions, dict): preserved_conclusions = {} + rollback_input = _deserialize_pipeline_input_content(restart.get("rollback_input")) return RestartInfo( start_from_step=start_from_step, preserved_conclusions=preserved_conclusions, rollback_context=rollback_context, + rollback_input=rollback_input, ) def _persisted_parallel_candidate_indices(self) -> list[int]: @@ -840,16 +946,91 @@ def _state_machine_snapshot_for_sidecar(self) -> dict[str, Any]: current_step_user_input = getattr(self, "_current_step_user_input", None) if current_step_user_input is not None: snapshot[_CURRENT_STEP_USER_INPUT_KEY] = current_step_user_input + current_step_user_input_content = getattr(self, "_current_step_user_input_content", None) + if current_step_user_input_content is not None: + snapshot[_CURRENT_STEP_USER_INPUT_CONTENT_KEY] = _serialize_pipeline_input_content( + current_step_user_input_content + ) + current_step_resume_messages = getattr(self, "_current_step_resume_messages", None) + if current_step_resume_messages is not None: + snapshot[_CURRENT_STEP_RESUME_MESSAGES_KEY] = _serialize_pipeline_messages(current_step_resume_messages) + current_step_precompleted_tools = getattr(self, "_current_step_precompleted_tools", None) + if current_step_precompleted_tools is not None: + snapshot[_CURRENT_STEP_PRECOMPLETED_TOOLS_KEY] = current_step_precompleted_tools return snapshot - def _set_current_step_user_input(self, user_input: str | list[ContentBlock] | None) -> None: - self._current_step_user_input = user_input if isinstance(user_input, str) else None + def _restore_current_step_user_input_from_snapshot(self, snapshot: dict[str, Any]) -> None: + restored_display_text = snapshot.get(_CURRENT_STEP_USER_INPUT_KEY) + if not isinstance(restored_display_text, str): + restored_display_text = None + restored_content = _deserialize_pipeline_input_content(snapshot.get(_CURRENT_STEP_USER_INPUT_CONTENT_KEY)) + if restored_content is None: + restored_content = restored_display_text + self._restored_current_step_user_input = ( + normalize_pipeline_user_input(restored_content, display_text=restored_display_text) + if restored_content is not None + else None + ) + self._current_step_user_input = restored_display_text + self._current_step_user_input_content = ( + self._restored_current_step_user_input.content + if self._restored_current_step_user_input is not None and self._restored_current_step_user_input.has_images + else None + ) + self._restored_current_step_resume_messages = _deserialize_pipeline_messages( + snapshot.get(_CURRENT_STEP_RESUME_MESSAGES_KEY) + ) + self._restored_current_step_precompleted_tools = _deserialize_precompleted_tools( + snapshot.get(_CURRENT_STEP_PRECOMPLETED_TOOLS_KEY) + ) + self._current_step_resume_messages = self._restored_current_step_resume_messages + self._current_step_precompleted_tools = self._restored_current_step_precompleted_tools + + def _set_current_step_user_input( + self, + user_input: str | list[ContentBlock] | PipelineUserInput | None, + *, + display_text: str | None = None, + ) -> None: + if user_input is None: + self._current_step_user_input = None + self._current_step_user_input_content = None + self._set_current_step_resume_state() + return + pipeline_input = normalize_pipeline_user_input(user_input, display_text=display_text) + self._current_step_user_input = display_text if display_text is not None else pipeline_input.display_text + self._current_step_user_input_content = pipeline_input.content if pipeline_input.has_images else None + + def _set_current_step_resume_state( + self, + *, + resume_messages: list[Message] | None = None, + precompleted_tools: dict[str, dict[str, Any]] | None = None, + ) -> None: + self._current_step_resume_messages = list(resume_messages) if resume_messages is not None else None + self._current_step_precompleted_tools = dict(precompleted_tools) if precompleted_tools is not None else None + + @staticmethod + def _continue_input_kwargs(user_input: PipelineUserInput) -> dict[str, Any]: + kwargs: dict[str, Any] = {"user_input": user_input.content} + if not isinstance(user_input.content, str) or user_input.display_text != user_input.content: + kwargs["user_input_display_text"] = user_input.display_text + return kwargs - def _consume_restored_current_step_user_input(self) -> str | None: + def _consume_restored_current_step_user_input(self) -> PipelineUserInput | None: user_input = self._restored_current_step_user_input self._restored_current_step_user_input = None return user_input + def _consume_restored_current_step_resume_state( + self, + ) -> tuple[list[Message] | None, dict[str, dict[str, Any]] | None]: + resume_messages = self._restored_current_step_resume_messages + precompleted_tools = self._restored_current_step_precompleted_tools + self._restored_current_step_resume_messages = None + self._restored_current_step_precompleted_tools = None + return resume_messages, precompleted_tools + def _step_attempts_from_snapshot(self, snapshot: dict[str, Any]) -> dict[str, int]: attempts = snapshot.get("step_attempts", {}) if not isinstance(attempts, dict): @@ -1583,14 +1764,17 @@ def _prompt_context_from_agent_loop( sub_pipeline_id=sub_pipeline_id, ) - async def run(self, user_input: str) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: + async def run( + self, user_input: str | list[ContentBlock] | PipelineUserInput + ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: """Start the pipeline from the first step.""" + pipeline_input = normalize_pipeline_user_input(user_input) self._session_storage.append_meta( self._cwd, self._session_id, {"type": "pipeline_init", "pipeline_type": self._loaded.name}, ) - self._set_current_step_user_input(user_input) + self._set_current_step_user_input(pipeline_input) await self._save_running(self.state_machine.current_step.step_id, reason="pipeline started") self._observability.pipeline_started( total_steps=self.state_machine.total_steps, @@ -1607,16 +1791,20 @@ async def run(self, user_input: str) -> AsyncGenerator[StreamEvent | PipelineEve }, ) with self._observability.pipeline_run_span(total_steps=self.state_machine.total_steps): - async for event in self._continue_from_current(user_input=user_input): + async for event in self._continue_from_current(**self._continue_input_kwargs(pipeline_input)): yield event - async def resume(self, user_input: str) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: + async def resume( + self, user_input: str | list[ContentBlock] | PipelineUserInput + ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: """Resume after user input at a USER_INPUT_REQUIRED pause.""" if self.has_pending_pipeline_pause_confirmation(): async for event in self._continue_from_sidecar_with_input(user_input): yield event return + pipeline_input = normalize_pipeline_user_input(user_input) + user_text = pipeline_input.display_text step = self.state_machine.current_step step_index = self.state_machine.current_step_index + 1 step_attempt = self._current_step_attempt(step.step_id) @@ -1637,11 +1825,11 @@ async def resume(self, user_input: str) -> AsyncGenerator[StreamEvent | Pipeline step_attempt=step_attempt, total_steps=self.state_machine.total_steps, ui_mode=step.ui_mode, - user_input=user_input, + user_input=user_text, wait_duration_ms=wait_duration_ms, ) if step.ui_mode == "candidate_selection": - selected_index = self._infer_selected_index(user_input, waiting_options) + selected_index = self._infer_selected_index(user_text, waiting_options) if selected_index is None: logger.debug( "Pipeline candidate selection did not match a unique option: step_id=%s option_count=%d", @@ -1655,9 +1843,9 @@ async def resume(self, user_input: str) -> AsyncGenerator[StreamEvent | Pipeline ui_mode=step.ui_mode, option_count=len(waiting_options), selected_index=selected_index, - selected_value=user_input, + selected_value=user_text, ) - current_conclusion["user_input"] = user_input + current_conclusion["user_input"] = user_text self.context.set_conclusion(step.conclusion_field, current_conclusion) yield PipelineEvent( @@ -1665,14 +1853,17 @@ async def resume(self, user_input: str) -> AsyncGenerator[StreamEvent | Pipeline step_id=step.step_id, timestamp=time.time(), data=_user_input_received_data( - user_input, + pipeline_input, ui_mode=step.ui_mode, selected_index=selected_index, waiting_options=waiting_options, ), ) - async for event in self._continue_from_current(user_input=user_input, resume_waiting_step=True): + async for event in self._continue_from_current( + **self._continue_input_kwargs(pipeline_input), + resume_waiting_step=True, + ): yield event async def resume_ask_user_question( @@ -1681,6 +1872,7 @@ async def resume_ask_user_question( *, tool_use_id: str, pending_input: dict[str, Any] | None = None, + supplemental_input: str | list[ContentBlock] | PipelineUserInput | None = None, ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: """Resume an in-step ask_user_question after process restart.""" payload = { @@ -1700,16 +1892,32 @@ async def resume_ask_user_question( content=json.dumps(payload, ensure_ascii=False), is_error=False, ) + supplemental = normalize_pipeline_user_input(supplemental_input) if supplemental_input is not None else None + if supplemental is not None and not supplemental.has_images: + supplemental = None + tool_result_message = Message(role="user", content=[tool_result]) + user_message: str | list[ContentBlock] | None = ( + supplemental.content if supplemental is not None else [tool_result] + ) candidate_index = _candidate_index_from_pending_input(pending_input) if candidate_index is not None: step = self.state_machine.current_step if step.step_type == PipelineStepType.PARALLEL_SUB_PIPELINE.value: previous = getattr(self, "_restored_ask_user_question", None) + candidate_resume_messages = [tool_result_message] if supplemental is not None else None + candidate_precompleted_tools = {"ask_user_question": payload} self._restored_ask_user_question = { "candidate_index": candidate_index, - "user_message": [tool_result], - "precompleted_tools": {"ask_user_question": payload}, + "user_message": user_message, + "resume_messages": candidate_resume_messages, + "precompleted_tools": candidate_precompleted_tools, } + self._set_candidate_ask_user_question_resume_state( + candidate_index, + user_message=user_message, + resume_messages=candidate_resume_messages, + precompleted_tools=candidate_precompleted_tools, + ) try: async for event in self._continue_from_current(resume_running_step=True): yield event @@ -1721,8 +1929,19 @@ async def resume_ask_user_question( self._restored_ask_user_question = previous return + if supplemental is not None: + async for event in self._continue_from_current( + **self._continue_input_kwargs(supplemental), + resume_messages=[*resume_messages, tool_result_message], + precompleted_tools={"ask_user_question": payload}, + resume_waiting_step=True, + ): + yield event + return + + resume_kwargs: dict[str, Any] = {"user_input": user_message} async for event in self._continue_from_current( - user_input=[tool_result], + **resume_kwargs, resume_messages=resume_messages, precompleted_tools={"ask_user_question": payload}, resume_waiting_step=True, @@ -1846,16 +2065,20 @@ def _get_state_for_judge(self) -> dict: return state - async def handle_user_interrupt(self, message: str) -> InterruptVerdict: + async def handle_user_interrupt(self, message: str | list[ContentBlock] | PipelineUserInput) -> InterruptVerdict: """Engine-layer interrupt entry point. All clients call this uniformly.""" - verdict = await self._interrupt_controller.judge(message) + pipeline_input = normalize_pipeline_user_input(message) + judge_input: str | PipelineUserInput = ( + pipeline_input if pipeline_input.has_images else pipeline_input.display_text + ) + verdict = await self._interrupt_controller.judge(judge_input) if self._is_judgment_error_verdict(verdict): return self._apply_interrupt_judge_failure_policy(verdict) if verdict.action == "supplement": - injected = self._inject_supplement(verdict, message) + injected = self._inject_supplement(verdict, pipeline_input.content) if not injected: # Don't silently lose the user's message — flag it via reason # prefix so the UI can render a clear "supplement was dropped" @@ -1908,7 +2131,25 @@ def resume_agent_loops(self) -> None: """ self._agent_pause_event.set() - def apply_hard_interrupt(self, verdict: InterruptVerdict) -> bool: + @staticmethod + def _input_for_interrupt_verdict( + verdict: InterruptVerdict, + source_input: str | list[ContentBlock] | PipelineUserInput | None, + ) -> PipelineUserInput | None: + source = normalize_pipeline_user_input(source_input) if source_input is not None else None + rollback_context = verdict.rollback_context or "" + if source is None: + return normalize_pipeline_user_input(rollback_context) if rollback_context else None + if rollback_context: + return source.with_prepended_text(rollback_context) + return source + + def apply_hard_interrupt( + self, + verdict: InterruptVerdict, + *, + source_input: str | list[ContentBlock] | PipelineUserInput | None = None, + ) -> bool: """Execute state rollback after hard interrupt. Returns True if a parent-level rollback was performed (caller should @@ -1923,6 +2164,7 @@ def apply_hard_interrupt(self, verdict: InterruptVerdict) -> bool: getattr(self, "_session_id", ""), ) self._rollback_context = None + self._rollback_input = None return False target = verdict.rollback_target or self.state_machine.current_step.step_id @@ -1968,7 +2210,10 @@ def apply_hard_interrupt(self, verdict: InterruptVerdict) -> bool: target = self.state_machine.current_step.step_id if is_candidate_restart: - self._schedule_candidate_restart(verdict) + if source_input is None: + self._schedule_candidate_restart(verdict) + else: + self._schedule_candidate_restart(verdict, source_input=source_input) self._emit_hard_interrupt_telemetry( rollback_scope="candidate", from_step=from_step, @@ -2007,6 +2252,7 @@ def apply_hard_interrupt(self, verdict: InterruptVerdict) -> bool: logger.warning("Cannot apply hard interrupt fallback target %r: %s", target, validation_error) self._save_failed_sync(from_step, fallback_reason) self._rollback_context = None + self._rollback_input = None return False verdict = replace(verdict, rollback_target=target, reason=fallback_reason) @@ -2018,8 +2264,10 @@ def apply_hard_interrupt(self, verdict: InterruptVerdict) -> bool: target_field = next((s.conclusion_field for s in self._loaded.steps if s.step_id == target), None) if target_field: self.context.mark_stale(target_field) - self._rollback_context = verdict.rollback_context - self._set_current_step_user_input(verdict.rollback_context) + rollback_input = self._input_for_interrupt_verdict(verdict, source_input) + self._rollback_context = rollback_input.display_text if rollback_input is not None else None + self._rollback_input = rollback_input.content if rollback_input is not None else None + self._set_current_step_user_input(rollback_input) self._mark_rollback_cleanup_required( cleanup_from_step, target, @@ -2116,19 +2364,26 @@ def _cancel_active_candidates(self, reason: str = "cancelled") -> list[asyncio.T def continue_after_interrupt(self) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: """Create a new event stream after interrupt rollback.""" + rollback_input = self._rollback_input context = self._rollback_context + self._rollback_input = None self._rollback_context = None + if rollback_input is not None: + kwargs: dict[str, Any] = {"user_input": rollback_input} + if not isinstance(rollback_input, str) or context != rollback_input: + kwargs["user_input_display_text"] = context + return self._continue_from_current(**kwargs) return self._continue_from_current(user_input=context) @staticmethod - def _try_inject_into_agent_loop(agent_loop: object | None, message: str) -> bool: + def _try_inject_into_agent_loop(agent_loop: object | None, message: PipelineInputContent) -> bool: if agent_loop is None: return False if inspect.getattr_static(agent_loop, "try_inject_user_message", None) is not None: try_inject = getattr(agent_loop, "try_inject_user_message", None) if callable(try_inject): - return bool(try_inject(message)) + return try_inject(message) is not False can_accept = getattr(agent_loop, "can_accept_injected_user_message", True) if can_accept is False: @@ -2140,7 +2395,7 @@ def _try_inject_into_agent_loop(agent_loop: object | None, message: str) -> bool inject(message) return True - def _inject_supplement(self, verdict: InterruptVerdict, message: str) -> bool: + def _inject_supplement(self, verdict: InterruptVerdict, message: PipelineInputContent) -> bool: """Inject supplement message into the correct AgentLoop. Returns True if the message was injected into at least one AgentLoop, @@ -2181,6 +2436,39 @@ def _inject_supplement(self, verdict: InterruptVerdict, message: str) -> bool: return self._try_inject_into_agent_loop(al, message) return False + @staticmethod + def _candidate_target_from_pending_question_envelope(envelope: dict[str, Any] | None) -> str | None: + if not isinstance(envelope, dict): + return None + candidate = envelope.get("candidate") + if not isinstance(candidate, dict): + return None + for key in ("index", "candidateIndex", "candidate_index"): + value = candidate.get(key) + if isinstance(value, int): + return f"candidate:{value}" + if isinstance(value, str): + try: + return f"candidate:{int(value)}" + except ValueError: + continue + return None + + def inject_pending_question_supplement( + self, + message: PipelineInputContent, + *, + envelope: dict[str, Any] | None = None, + ) -> bool: + """Inject image/text supplied alongside an active ask_user_question answer.""" + target = self._candidate_target_from_pending_question_envelope(envelope) + verdict = InterruptVerdict( + action="supplement", + reason="ask_user_question supplemental input", + supplement_target=target, + ) + return self._inject_supplement(verdict, message) + @staticmethod def _candidate_index_from_target(target: str | None) -> int | None: if not target or not (target.startswith("candidate:") or target.startswith("candidate_index:")): @@ -2190,6 +2478,59 @@ def _candidate_index_from_target(target: str | None) -> int | None: except (ValueError, IndexError): return None + def _candidate_execution_state_for_resume(self, candidate_index: int) -> dict[str, Any] | None: + execution = getattr(self, "_execution", None) + if not isinstance(execution, dict): + return None + candidates = execution.get("candidates") + if not isinstance(candidates, dict): + return None + for key in (str(candidate_index), candidate_index): + state = candidates.get(key) + if isinstance(state, dict): + return state + return None + + def _set_candidate_ask_user_question_resume_state( + self, + candidate_index: int, + *, + user_message: PipelineInputContent, + resume_messages: list[Message] | None, + precompleted_tools: dict[str, dict[str, Any]] | None, + ) -> None: + execution = self._execution if isinstance(self._execution, dict) else {} + candidates = execution.setdefault("candidates", {}) + if not isinstance(candidates, dict): + candidates = {} + execution["candidates"] = candidates + state = candidates.setdefault(str(candidate_index), {}) + if not isinstance(state, dict): + state = {} + candidates[str(candidate_index)] = state + state[_PENDING_ASK_USER_QUESTION_RESUME_KEY] = _serialize_ask_user_question_resume_state( + user_message=user_message, + resume_messages=resume_messages, + precompleted_tools=precompleted_tools, + ) + self._execution = execution + + def _candidate_ask_user_question_resume_state(self, candidate_index: int) -> dict[str, Any] | None: + restored_ask = getattr(self, "_restored_ask_user_question", None) + if isinstance(restored_ask, dict) and restored_ask.get("candidate_index") == candidate_index: + return restored_ask + active_state = getattr(self, "_active_candidates", {}).get(candidate_index) + if isinstance(active_state, dict): + restored = _deserialize_ask_user_question_resume_state( + active_state.get(_PENDING_ASK_USER_QUESTION_RESUME_KEY) + ) + if restored is not None: + return restored + state = self._candidate_execution_state_for_resume(candidate_index) + if state is None: + return None + return _deserialize_ask_user_question_resume_state(state.get(_PENDING_ASK_USER_QUESTION_RESUME_KEY)) + def _candidate_user_message_for_restored_supplement( self, candidate_index: int, @@ -2208,20 +2549,24 @@ def _candidate_user_message_for_restored_ask_user_question( self, candidate_index: int, ) -> list[ContentBlock] | None: - restored_ask = getattr(self, "_restored_ask_user_question", None) - if not isinstance(restored_ask, dict) or restored_ask.get("candidate_index") != candidate_index: - return None - user_message = restored_ask.get("user_message") + restored_ask = self._candidate_ask_user_question_resume_state(candidate_index) + user_message = restored_ask.get("user_message") if restored_ask is not None else None return user_message if isinstance(user_message, list) else None + def _candidate_resume_messages_for_restored_ask_user_question( + self, + candidate_index: int, + ) -> list[Message] | None: + restored_ask = self._candidate_ask_user_question_resume_state(candidate_index) + resume_messages = restored_ask.get("resume_messages") if restored_ask is not None else None + return resume_messages if isinstance(resume_messages, list) else None + def _candidate_precompleted_tools_for_restored_ask_user_question( self, candidate_index: int, ) -> dict[str, dict[str, Any]] | None: - restored_ask = getattr(self, "_restored_ask_user_question", None) - if not isinstance(restored_ask, dict) or restored_ask.get("candidate_index") != candidate_index: - return None - precompleted_tools = restored_ask.get("precompleted_tools") + restored_ask = self._candidate_ask_user_question_resume_state(candidate_index) + precompleted_tools = restored_ask.get("precompleted_tools") if restored_ask is not None else None return precompleted_tools if isinstance(precompleted_tools, dict) else None def _requested_candidate_indices(self, scope: str | None) -> list[int]: @@ -2277,12 +2622,18 @@ def _candidate_current_sub_step_index(self, state: dict[str, Any], step_ids: lis return None - def _schedule_candidate_restart(self, verdict: InterruptVerdict) -> None: + def _schedule_candidate_restart( + self, + verdict: InterruptVerdict, + *, + source_input: str | list[ContentBlock] | PipelineUserInput | None = None, + ) -> None: """Cancel specified candidate(s) and schedule restart.""" target_step = verdict.rollback_target indices = self._requested_candidate_indices(verdict.candidate_scope) current_step = self.state_machine.current_step sub_spec = self._loaded.sub_pipelines.get(current_step.sub_pipeline_name or "") + rollback_input = self._input_for_interrupt_verdict(verdict, source_input) for idx in indices: state = self._active_candidates.get(idx) @@ -2296,7 +2647,10 @@ def _schedule_candidate_restart(self, verdict: InterruptVerdict) -> None: self._pending_candidate_restarts[idx] = RestartInfo( start_from_step=target_step, preserved_conclusions=preserved, - rollback_context=verdict.rollback_context, + rollback_context=rollback_input.display_text + if rollback_input is not None + else verdict.rollback_context, + rollback_input=rollback_input.content if rollback_input is not None else None, ) if target_step and sub_spec: sub_pipeline_id = state.get("sub_pipeline_id") or f"{sub_spec.name}_candidate_{idx}" @@ -2336,6 +2690,15 @@ def _schedule_candidate_restart(self, verdict: InterruptVerdict) -> None: for stale_step in sub_spec.steps[target_index:]: sub_context.mark_stale(stale_step.conclusion_field) context_snapshot = sub_context.to_snapshot() + pending_restart: dict[str, Any] = { + "start_from_step": target_step, + "preserved_conclusions": preserved, + "rollback_context": rollback_input.display_text + if rollback_input is not None + else verdict.rollback_context, + } + if rollback_input is not None and rollback_input.has_images: + pending_restart["rollback_input"] = _serialize_pipeline_input_content(rollback_input.content) entry = { "status": "running", "candidate": state.get("candidate", state.get("raw_candidate", {})), @@ -2347,11 +2710,7 @@ def _schedule_candidate_restart(self, verdict: InterruptVerdict) -> None: "active_attempt_id": new_attempt["attempt_id"], "transcript_id": new_attempt["transcript_id"], "conclusions": preserved, - "pending_restart": { - "start_from_step": target_step, - "preserved_conclusions": preserved, - "rollback_context": verdict.rollback_context, - }, + "pending_restart": pending_restart, } existing_candidate = self._execution.get("candidates", {}).get(str(idx), {}).get("candidate") if existing_candidate is not None: @@ -2383,6 +2742,7 @@ async def _continue_from_current( self, user_input: str | list[ContentBlock] | None = None, *, + user_input_display_text: str | None = None, resume_messages: list[Message] | None = None, precompleted_tools: dict[str, dict[str, Any]] | None = None, resume_waiting_step: bool = False, @@ -2392,8 +2752,35 @@ async def _continue_from_current( terminal_pipeline_telemetry_emitted = False step_result: StepResult | None = None restored_step_user_input = self._consume_restored_current_step_user_input() if user_input is None else None - first_step_user_input = user_input if user_input is not None else restored_step_user_input + restored_resume_messages, restored_precompleted_tools = ( + self._consume_restored_current_step_resume_state() if user_input is None else (None, None) + ) + first_step_user_input = ( + user_input + if user_input is not None + else restored_step_user_input.content + if restored_step_user_input is not None + else None + ) + first_step_user_input_display_text = ( + user_input_display_text + if user_input is not None + else restored_step_user_input.display_text + if restored_step_user_input is not None + else None + ) first_step_user_input_is_restored = user_input is None and restored_step_user_input is not None + first_step_resume_messages = resume_messages if resume_messages is not None else restored_resume_messages + first_step_precompleted_tools = ( + precompleted_tools if precompleted_tools is not None else restored_precompleted_tools + ) + if first_step_resume_messages is not None or first_step_precompleted_tools is not None: + self._set_current_step_resume_state( + resume_messages=first_step_resume_messages, + precompleted_tools=first_step_precompleted_tools, + ) + elif user_input is not None: + self._set_current_step_resume_state() def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: nonlocal terminal_pipeline_telemetry_emitted @@ -2409,7 +2796,8 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: while not self.state_machine.is_complete: step = self.state_machine.current_step step_user_message = first_step_user_input if is_first_step else None - self._set_current_step_user_input(step_user_message) + step_user_display_text = first_step_user_input_display_text if is_first_step else None + self._set_current_step_user_input(step_user_message, display_text=step_user_display_text) step_start = time.time() step_started_at = self._observability.now() step_index = self.state_machine.current_step_index + 1 @@ -2560,20 +2948,23 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: ): first_step = is_first_step is_first_step = False - step_resume_messages = resume_messages if first_step else None - step_precompleted_tools = precompleted_tools if first_step else None - if ( - step_resume_messages is None - and self._transcript_storage is not None - and attempt.get("status") == "running" - ): + step_resume_messages = first_step_resume_messages if first_step else None + step_precompleted_tools = first_step_precompleted_tools if first_step else None + if self._transcript_storage is not None and attempt.get("status") == "running": loaded = self._transcript_storage.load(self._cwd, attempt["transcript_id"]) - step_resume_messages = self._transcript_storage.repair_interrupted(loaded) + repaired_resume_messages = self._transcript_storage.repair_interrupted(loaded) + step_resume_messages = reconcile_resume_messages( + repaired_resume_messages, + step_resume_messages, + ) if ( first_step and first_step_user_input_is_restored - and isinstance(step_user_message, str) and step_resume_messages + and ( + isinstance(step_user_message, str) + or user_message_already_in_resume(step_user_message, step_resume_messages) + ) ): step_user_message = None execute_kwargs: dict[str, Any] = { @@ -3000,6 +3391,12 @@ async def save_candidate_execution_state( conclusions = payload.get("conclusions") if conclusions is not None: entry["conclusions"] = conclusions + active_state = self._active_candidates.get(i) + pending_ask_resume = ( + active_state.get(_PENDING_ASK_USER_QUESTION_RESUME_KEY) if isinstance(active_state, dict) else None + ) + if pending_ask_resume is not None and entry["status"] == "running": + entry[_PENDING_ASK_USER_QUESTION_RESUME_KEY] = pending_ask_resume self._execution.setdefault("candidates", {})[str(i)] = entry await self._save_running(step.step_id, reason=reason or "parallel sub-pipeline running") @@ -3063,6 +3460,9 @@ async def run_candidate( "active_attempt_id": (resume_state or {}).get("active_attempt_id"), "transcript_id": (resume_state or {}).get("transcript_id"), } + pending_ask_resume = (resume_state or {}).get(_PENDING_ASK_USER_QUESTION_RESUME_KEY) + if pending_ask_resume is not None: + state[_PENDING_ASK_USER_QUESTION_RESUME_KEY] = pending_ask_resume self._active_candidates[i] = state def allocate_sub_step_attempt(request: dict[str, Any]) -> dict[str, Any]: @@ -3107,11 +3507,14 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: start_from_step = restart_info.start_from_step if restart_info else None preserved_conclusions = restart_info.preserved_conclusions if restart_info else None candidate_user_message = ( - restart_info.rollback_context + restart_info.rollback_input + if restart_info and restart_info.rollback_input is not None + else restart_info.rollback_context if restart_info else self._candidate_user_message_for_restored_supplement(i, user_message) ) ask_user_message = self._candidate_user_message_for_restored_ask_user_question(i) + candidate_resume_messages = self._candidate_resume_messages_for_restored_ask_user_question(i) candidate_precompleted_tools = self._candidate_precompleted_tools_for_restored_ask_user_question(i) if ask_user_message is not None: candidate_user_message = ask_user_message @@ -3128,6 +3531,8 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: } if "precompleted_tools" in parameters or has_var_keyword: recovery_kwargs["precompleted_tools"] = candidate_precompleted_tools + if "resume_messages" in parameters or has_var_keyword: + recovery_kwargs["resume_messages"] = candidate_resume_messages event_stream = execute_streaming( sub_spec=sub_spec, candidate=candidate, diff --git a/src/iac_code/pipeline/engine/resume_recovery.py b/src/iac_code/pipeline/engine/resume_recovery.py new file mode 100644 index 00000000..526d2aea --- /dev/null +++ b/src/iac_code/pipeline/engine/resume_recovery.py @@ -0,0 +1,72 @@ +"""Helpers for reconciling durable transcript recovery with sidecar resume state.""" + +from __future__ import annotations + +import json + +from iac_code.agent.message import ContentBlock, Message, ToolResultBlock + + +def _message_key(message: Message) -> str: + return json.dumps(message.to_dict(), ensure_ascii=False, sort_keys=True) + + +def _tool_result_ids(message: Message) -> set[str]: + if not isinstance(message.content, list): + return set() + return {block.tool_use_id for block in message.content if isinstance(block, ToolResultBlock) and block.tool_use_id} + + +def _without_seen_tool_results(message: Message, seen_tool_result_ids: set[str]) -> Message | None: + if not isinstance(message.content, list): + return message + content = [ + block + for block in message.content + if not isinstance(block, ToolResultBlock) or block.tool_use_id not in seen_tool_result_ids + ] + if not content: + return None + if len(content) == len(message.content): + return message + return message.model_copy(update={"content": content}) + + +def reconcile_resume_messages( + transcript_messages: list[Message] | None, + sidecar_messages: list[Message] | None, +) -> list[Message] | None: + """Merge sidecar resume messages into repaired transcript messages without duplicating tool results.""" + merged = list(transcript_messages or []) + if not sidecar_messages: + return merged or None + if not merged: + return list(sidecar_messages) + + seen_keys = {_message_key(message) for message in merged} + seen_tool_result_ids: set[str] = set() + for message in merged: + seen_tool_result_ids.update(_tool_result_ids(message)) + + for message in sidecar_messages: + key = _message_key(message) + if key in seen_keys: + continue + filtered = _without_seen_tool_results(message, seen_tool_result_ids) + if filtered is None: + continue + merged.append(filtered) + seen_keys.add(_message_key(filtered)) + seen_tool_result_ids.update(_tool_result_ids(filtered)) + return merged or None + + +def user_message_already_in_resume( + user_message: str | list[ContentBlock] | None, + resume_messages: list[Message] | None, +) -> bool: + if user_message is None or not resume_messages: + return False + candidate = Message(role="user", content=user_message) + candidate_key = _message_key(candidate) + return any(_message_key(message) == candidate_key for message in resume_messages) diff --git a/src/iac_code/pipeline/engine/sub_pipeline_executor.py b/src/iac_code/pipeline/engine/sub_pipeline_executor.py index 81098275..48f78353 100644 --- a/src/iac_code/pipeline/engine/sub_pipeline_executor.py +++ b/src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -12,12 +12,13 @@ from pathlib import Path from typing import Any -from iac_code.agent.message import ContentBlock +from iac_code.agent.message import ContentBlock, Message from iac_code.i18n import _ from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.observability import PipelineObservability from iac_code.pipeline.engine.public_errors import public_error, public_error_from_exception +from iac_code.pipeline.engine.resume_recovery import reconcile_resume_messages, user_message_already_in_resume from iac_code.pipeline.engine.state_machine import StateMachine from iac_code.pipeline.engine.step_executor import StepExecutor from iac_code.pipeline.engine.step_spec import LoadedPipeline, SubPipelineSpec @@ -347,6 +348,7 @@ async def execute_streaming( start_from_step: str | None = None, preserved_conclusions: dict[str, Any] | None = None, user_message: str | list[ContentBlock] | None = None, + resume_messages: list[Message] | None = None, parent_step_id: str | None = None, resume_state: dict[str, Any] | None = None, sub_step_attempt_allocator: Callable[[dict[str, Any]], dict[str, Any]] | None = None, @@ -578,7 +580,19 @@ def sub_step_attrs_for_current(step, step_index: int) -> dict[str, Any]: step_msg = user_message if is_first_step else None step_precompleted_tools = precompleted_tools if is_first_step else None - if isinstance(step_msg, str) and attempt_info.get("resume_messages"): + attempt_resume_messages = attempt_info.get("resume_messages") + if not isinstance(attempt_resume_messages, list): + attempt_resume_messages = [] + explicit_resume_messages = ( + resume_messages if is_first_step and resume_messages is not None else [] + ) + step_resume_messages = reconcile_resume_messages( + attempt_resume_messages, + explicit_resume_messages, + ) + if step_resume_messages and ( + isinstance(step_msg, str) or user_message_already_in_resume(step_msg, step_resume_messages) + ): step_msg = None is_first_step = False step_result: StepResult | None = None @@ -587,7 +601,7 @@ def sub_step_attrs_for_current(step, step_index: int) -> dict[str, Any]: "user_message": step_msg, "attempt_id": attempt_info.get("attempt_id"), "transcript_id": attempt_info.get("transcript_id"), - "resume_messages": attempt_info.get("resume_messages"), + "resume_messages": step_resume_messages, "precompleted_tools": step_precompleted_tools, "rollback_targets": state_machine.completed_non_future_rollback_targets(), "rollback_count": state_machine.rollback_count, diff --git a/src/iac_code/pipeline/engine/user_input.py b/src/iac_code/pipeline/engine/user_input.py new file mode 100644 index 00000000..b77cb458 --- /dev/null +++ b/src/iac_code/pipeline/engine/user_input.py @@ -0,0 +1,90 @@ +"""User input wrapper for pipeline entry points. + +Pipeline execution needs the original content blocks for model calls, while +UI, A2A status, telemetry, and sidecar metadata need text-only display data. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from iac_code.agent.message import ContentBlock, ImageBlock, TextBlock, ToolResultBlock + +PipelineInputContent = str | list[ContentBlock] +IMAGE_INPUT_PLACEHOLDER = "[Image input]" + + +def content_has_images(content: PipelineInputContent | None) -> bool: + if not isinstance(content, list): + return False + return any(isinstance(block, ImageBlock) or getattr(block, "type", None) == "image" for block in content) + + +def content_display_text(content: PipelineInputContent | None) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + parts: list[str] = [] + has_images = False + for block in content: + if isinstance(block, TextBlock): + parts.append(block.text) + continue + if isinstance(block, ToolResultBlock): + parts.append(block.content) + continue + if isinstance(block, ImageBlock) or getattr(block, "type", None) == "image": + has_images = True + text = "\n".join(part for part in parts if part) + if text.strip(): + return text + return IMAGE_INPUT_PLACEHOLDER if has_images else "" + + +@dataclass(frozen=True) +class PipelineUserInput: + content: PipelineInputContent + display_text: str + has_images: bool + + @property + def is_empty(self) -> bool: + return not self.display_text.strip() and not self.has_images + + def with_prepended_text(self, text: str) -> "PipelineUserInput": + prefix = text.strip() + if not prefix: + return self + if isinstance(self.content, str): + content: PipelineInputContent = f"{prefix}\n\n{self.content}" if self.content else prefix + else: + content = [TextBlock(text=prefix), *self.content] + display_text = f"{prefix}\n\n{self.display_text}" if self.display_text.strip() else prefix + return PipelineUserInput( + content=content, + display_text=display_text, + has_images=content_has_images(content), + ) + + +def normalize_pipeline_user_input( + user_input: str | list[ContentBlock] | PipelineUserInput | None, + *, + display_text: str | None = None, +) -> PipelineUserInput: + if isinstance(user_input, PipelineUserInput): + if display_text is None: + return user_input + return PipelineUserInput( + content=user_input.content, + display_text=display_text or content_display_text(user_input.content), + has_images=user_input.has_images, + ) + content: PipelineInputContent = "" if user_input is None else user_input + resolved_display_text = display_text if display_text is not None else content_display_text(content) + return PipelineUserInput( + content=content, + display_text=resolved_display_text, + has_images=content_has_images(content), + ) diff --git a/src/iac_code/ui/core/prompt_input.py b/src/iac_code/ui/core/prompt_input.py index 5d1d8de8..fc86199b 100644 --- a/src/iac_code/ui/core/prompt_input.py +++ b/src/iac_code/ui/core/prompt_input.py @@ -643,7 +643,7 @@ def _input_loop(self, prompt: str, *, initial_text: str = "", transient: bool = self._buffer = list(initial_text) self._cursor = len(self._buffer) self._pasted_contents = {} - self._next_paste_id = 1 + self._next_paste_id = self._initial_paste_id() self._submitted = False self._cancelled = False self._esc_pressed = False @@ -742,3 +742,13 @@ def _input_loop(self, prompt: str, *, initial_text: str = "", transient: bool = if self._cancelled: return None return self._get_text() + + def _initial_paste_id(self) -> int: + next_image_id = getattr(self._image_store, "next_image_id", None) + if not callable(next_image_id): + return 1 + try: + value = int(next_image_id()) + except Exception: + return 1 + return value if value > 0 else 1 diff --git a/src/iac_code/ui/renderer.py b/src/iac_code/ui/renderer.py index 7ef0bb03..b034c927 100644 --- a/src/iac_code/ui/renderer.py +++ b/src/iac_code/ui/renderer.py @@ -18,6 +18,7 @@ import threading import time from dataclasses import dataclass, field +from pathlib import Path from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable if sys.platform != "win32": @@ -32,6 +33,7 @@ from rich.markdown import ListItem, Markdown from rich.rule import Rule from rich.segment import Segment +from rich.style import Style from rich.table import Table from rich.text import Text @@ -270,10 +272,15 @@ def __init__( tool_registry: "ToolRegistry", status_callback: Callable[[], str] | None = None, app_state_store: "AppStateStore | None" = None, + *, + image_path_resolver: Callable[[int], str | None] | None = None, + image_block_path_resolver: Callable[[Any], str | None] | None = None, ) -> None: self.console = console self._tool_registry = tool_registry self._status_callback = status_callback + self._image_path_resolver = image_path_resolver + self._image_block_path_resolver = image_block_path_resolver self._verbose = False self._text_flushed = False # tracks whether current text block was partially flushed self._message_history: list[RenderedTurn] = [] @@ -331,6 +338,37 @@ def print_user_message(self, text: str) -> None: t.append(text) self.console.print(t) + def _image_ref_style(self, image_id: int) -> Style | None: + if self._image_path_resolver is None: + return None + try: + image_path = self._image_path_resolver(image_id) + if not image_path: + return None + return Style(color="cyan", link=self._file_url(image_path)) + except Exception: + return None + + @staticmethod + def _file_url(path: str) -> str: + resolved = Path(path).expanduser() + if not resolved.is_absolute(): + resolved = resolved.resolve(strict=False) + return resolved.as_uri() + + def _image_block_style(self, block: Any) -> Style | None: + if self._image_block_path_resolver is not None: + try: + image_path = self._image_block_path_resolver(block) + if image_path: + return Style(color="cyan", link=self._file_url(image_path)) + except Exception: + pass + ref_id = getattr(block, "ref_id", None) + if isinstance(ref_id, int): + return self._image_ref_style(ref_id) + return None + def print_command_result(self, command: str, result: str) -> None: t = Text() t.append(" └ ", style="dim") @@ -1795,7 +1833,13 @@ def _format_token_count(count: int, label: str) -> str: def replay_history(self, messages: list) -> None: """Replay saved Message objects to scrollback with 1:1 visual fidelity.""" - from iac_code.agent.message import TextBlock, ToolResultBlock, ToolUseBlock, is_recalled_memory_message + from iac_code.agent.message import ( + ImageBlock, + TextBlock, + ToolResultBlock, + ToolUseBlock, + is_recalled_memory_message, + ) from iac_code.pipeline.engine.cleanup import is_cleanup_prompt_message # Build a lookup of tool_use_id → ToolResultBlock from all user messages @@ -1823,12 +1867,13 @@ def replay_history(self, messages: list) -> None: if not first_turn: self.console.print() first_turn = False - if isinstance(msg.content, str): - self.print_user_message(msg.content) - else: - text = msg.get_text() - if text: - self.print_user_message(text) + rendered = self._render_user_content( + msg.content, + text_block_type=TextBlock, + image_block_type=ImageBlock, + ) + if rendered.plain.strip(): + self.console.print(rendered) self.console.print() # blank line between user input and agent response elif msg.role == "assistant": segments: list[_Segment] = [] @@ -1859,6 +1904,25 @@ def replay_history(self, messages: list) -> None: Text(f"✻ {random_completion_verb()} {_format_elapsed(msg.elapsed_seconds)}", style="dim italic") ) + def _render_user_content(self, content: Any, *, text_block_type: type, image_block_type: type) -> Text: + text = Text() + text.append("❯ ", style="bold cyan") + if isinstance(content, str): + text.append(content) + return text + if not isinstance(content, list): + return text + image_count = 0 + for block in content: + if isinstance(block, text_block_type): + text.append(block.text) + elif isinstance(block, image_block_type): + image_count += 1 + image_id = getattr(block, "ref_id", None) + label_id = image_id if isinstance(image_id, int) else image_count + text.append(f"[Image #{label_id}]", style=self._image_block_style(block)) + return text + @staticmethod def is_internal_skill_context_message(message: Any) -> bool: content = getattr(message, "content", None) diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index aac95763..2428ed39 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -91,6 +91,7 @@ from iac_code.pipeline import PipelineRunner from iac_code.pipeline.config import RunMode from iac_code.pipeline.engine.events import PipelineEvent + from iac_code.pipeline.engine.user_input import PipelineUserInput termios: ModuleType | None try: @@ -261,6 +262,8 @@ def __init__( self.tool_registry, status_callback=self._status_text, app_state_store=self.store, + image_path_resolver=self._image_store.get_path, + image_block_path_resolver=self._image_store.store_block, ) self._pipeline: PipelineRunner | None = None @@ -1276,6 +1279,8 @@ def _render_pipeline_display_transcript_window(self, messages: list[Message]) -> self.tool_registry, status_callback=self._status_text, app_state_store=self.store, + image_path_resolver=self._image_store.get_path, + image_block_path_resolver=self._image_store.store_block, ) temp_renderer.replay_history(messages) rendered = stream.getvalue().rstrip() @@ -2105,17 +2110,7 @@ async def _handle_chat(self, user_input: PromptInputResult | str) -> list[str]: from iac_code.pipeline.config import RunMode if self._get_runtime_mode() == RunMode.PIPELINE: - # Pipeline mode doesn't accept multimodal input — flatten to text. - text = user_input.text if isinstance(user_input, PromptInputResult) else user_input - # U-I4: warn user if we're about to drop pasted image content. - if isinstance(user_input, PromptInputResult) and user_input.pasted_contents: - has_image = any(pc.type == "image" for pc in user_input.pasted_contents.values()) - if has_image: - self.renderer.print_system_message( - _("Note: images are not supported in pipeline mode and will be ignored."), - style="yellow", - ) - await self._handle_pipeline_chat(text) + await self._handle_pipeline_chat(self._pipeline_user_input_from_repl_input(user_input)) return [] draft_text = user_input.text if isinstance(user_input, PromptInputResult) else user_input @@ -2318,12 +2313,41 @@ async def ensure_pipeline_restored_for_prompt(self) -> bool: self._pipeline_waiting_input = restored.status == "waiting_input" return True - async def _handle_pipeline_chat(self, user_input: str) -> None: + def _pipeline_user_input_from_repl_input( + self, user_input: PromptInputResult | str | "PipelineUserInput" | None + ) -> "PipelineUserInput": + """Convert REPL input to the pipeline wrapper used by model-facing entry points.""" + from iac_code.pipeline.engine.user_input import normalize_pipeline_user_input + from iac_code.utils.image.processor import process_user_input + + if isinstance(user_input, PromptInputResult): + blocks = process_user_input(user_input.text, pasted_contents=user_input.pasted_contents) + content: str | list[ContentBlock] + if any(isinstance(block, ImageBlock) for block in blocks): + content = blocks + else: + content = user_input.text + return normalize_pipeline_user_input(content, display_text=user_input.text) + return normalize_pipeline_user_input(user_input) + + async def _read_pipeline_interrupt_input(self) -> "PipelineUserInput": + user_input = await self._prompt_input.get_input(prompt="✎ ", transient=True) + if user_input is not None: + make_result = getattr(self._prompt_input, "make_result", None) + if callable(make_result): + result = make_result() + if isinstance(result, PromptInputResult): + return self._pipeline_user_input_from_repl_input(result) + return self._pipeline_user_input_from_repl_input(user_input) + + async def _handle_pipeline_chat(self, user_input: str | "PipelineUserInput") -> None: """Drive the pipeline and render output.""" from iac_code.pipeline import create_pipeline from iac_code.pipeline.config import get_pipeline_name, get_working_directory + from iac_code.pipeline.engine.user_input import normalize_pipeline_user_input - self.renderer.record_user_turn(user_input) + pipeline_input = normalize_pipeline_user_input(user_input) + self.renderer.record_user_turn(pipeline_input.display_text) if self._pipeline is None: pipeline_cwd = get_working_directory() or self._original_cwd @@ -2358,13 +2382,13 @@ async def _handle_pipeline_chat(self, user_input: str) -> None: if self._pipeline_current_step_is_candidate_selection() is True: resume_waiting_candidate_selection = True else: - event_stream = self._pipeline.resume(user_input) + event_stream = cast(Any, self._pipeline).resume(pipeline_input) elif restored and restored.ok and restored.status == "running": self._pipeline_waiting_input = False - event_stream = self._pipeline.continue_from_sidecar(user_input=user_input) + event_stream = cast(Any, self._pipeline).continue_from_sidecar(user_input=pipeline_input) else: - self._persist_pipeline_visible_user_turn(user_input) - event_stream = self._pipeline.run(user_input) + self._persist_pipeline_visible_user_turn(pipeline_input) + event_stream = cast(Any, self._pipeline).run(pipeline_input) else: self._refresh_pipeline_display_recorder() self._pipeline_waiting_input = False @@ -2373,14 +2397,14 @@ async def _handle_pipeline_chat(self, user_input: str) -> None: resume_waiting_candidate_selection = False event_stream = None if restored_status == "running": - event_stream = self._pipeline.continue_from_sidecar(user_input=user_input) + event_stream = cast(Any, self._pipeline).continue_from_sidecar(user_input=pipeline_input) elif restored_status == "waiting_input": if self._pipeline_current_step_is_candidate_selection() is True: resume_waiting_candidate_selection = True else: - event_stream = self._pipeline.resume(user_input) + event_stream = cast(Any, self._pipeline).resume(pipeline_input) else: - event_stream = self._pipeline.resume(user_input) + event_stream = cast(Any, self._pipeline).resume(pipeline_input) # No except for CancelledError/KeyboardInterrupt here: Ctrl+C must # propagate to the run() loop's single handler (which keeps the REPL @@ -2650,7 +2674,9 @@ def _handoff_pipeline_to_normal(self, terminal_event: PipelineEvent | None) -> P ) return "succeeded" - async def _handle_mid_pipeline_message(self, msg: str, suppress_render: bool = False) -> tuple[bool, str]: + async def _handle_mid_pipeline_message( + self, msg: PromptInputResult | str | "PipelineUserInput", suppress_render: bool = False + ) -> tuple[bool, str]: """Process a user message received during pipeline execution via judge. Returns (needs_restart, feedback_text). When suppress_render is True, @@ -2659,6 +2685,10 @@ async def _handle_mid_pipeline_message(self, msg: str, suppress_render: bool = F """ if self._pipeline is None: return False, "" + pipeline_input = self._pipeline_user_input_from_repl_input(msg) + if pipeline_input.is_empty: + return False, "" + display_text = pipeline_input.display_text from rich.spinner import Spinner @@ -2668,11 +2698,11 @@ async def _handle_mid_pipeline_message(self, msg: str, suppress_render: bool = F refresh_per_second=10, transient=True, ): - verdict = await self._pipeline.handle_user_interrupt(msg) + verdict = await cast(Any, self._pipeline).handle_user_interrupt(pipeline_input) self._last_interrupt_paused = bool(getattr(verdict, "paused", False)) if verdict.action == "continue": - feedback = self._format_interrupt_feedback("continue", msg, verdict) + feedback = self._format_interrupt_feedback("continue", display_text, verdict) if getattr(verdict, "paused", False): save_interrupt_pause = getattr(self._pipeline, "save_interrupt_pause", None) if callable(save_interrupt_pause): @@ -2688,27 +2718,30 @@ async def _handle_mid_pipeline_message(self, msg: str, suppress_render: bool = F style="yellow", ) if not suppress_render: - self._render_interrupt_feedback("continue", msg, verdict) + self._render_interrupt_feedback("continue", display_text, verdict) return False, feedback if verdict.action == "supplement": - feedback = self._format_interrupt_feedback("supplement", msg, verdict) + feedback = self._format_interrupt_feedback("supplement", display_text, verdict) if not suppress_render: - self._render_interrupt_feedback("supplement", msg, verdict) + self._render_interrupt_feedback("supplement", display_text, verdict) return False, feedback if verdict.action == "hard_interrupt": - is_parent_rollback = self._pipeline.apply_hard_interrupt(verdict) + if pipeline_input.has_images: + is_parent_rollback = self._pipeline.apply_hard_interrupt(verdict, source_input=pipeline_input) + else: + is_parent_rollback = self._pipeline.apply_hard_interrupt(verdict) applied_verdict = getattr(self._pipeline, "last_applied_interrupt_verdict", None) feedback_verdict = ( applied_verdict if getattr(applied_verdict, "action", None) == "hard_interrupt" else verdict ) if not is_parent_rollback: - feedback = self._format_interrupt_feedback("hard_interrupt_candidate", msg, feedback_verdict) + feedback = self._format_interrupt_feedback("hard_interrupt_candidate", display_text, feedback_verdict) if not suppress_render: - self._render_interrupt_feedback("hard_interrupt_candidate", msg, feedback_verdict) + self._render_interrupt_feedback("hard_interrupt_candidate", display_text, feedback_verdict) return False, feedback - feedback = self._format_interrupt_feedback("hard_interrupt_parent", msg, feedback_verdict) + feedback = self._format_interrupt_feedback("hard_interrupt_parent", display_text, feedback_verdict) if not suppress_render: - self._render_interrupt_feedback("hard_interrupt_parent", msg, feedback_verdict) + self._render_interrupt_feedback("hard_interrupt_parent", display_text, feedback_verdict) return True, feedback return False, "" @@ -2929,10 +2962,10 @@ async def _stop_renderer() -> bool: self._pipeline.pause_agent_loops() try: had_renderer = await _stop_renderer() - user_input = await self._prompt_input.get_input(prompt="✎ ", transient=True) - if user_input and user_input.strip(): + user_input = await self._read_pipeline_interrupt_input() + if not user_input.is_empty: needs_restart, feedback = await self._handle_mid_pipeline_message( - user_input.strip(), suppress_render=True + user_input, suppress_render=True ) if needs_restart and self._pipeline: event_stream = await self._restart_pipeline_stream_after_interrupt( @@ -3191,9 +3224,6 @@ def _live_update(content): stop_keys = asyncio.Event() interrupt_requested = asyncio.Event() - input_mode = False - input_chars: list[str] = [] - input_done = asyncio.Event() parent_task = asyncio.current_task() def _request_pipeline_cancel() -> None: @@ -3203,7 +3233,6 @@ def _request_pipeline_cancel() -> None: parent_task.cancel() async def key_reader(): - nonlocal input_mode loop = asyncio.get_running_loop() try: with RawInputCapture(use_cbreak=True) as cap: @@ -3216,26 +3245,6 @@ async def key_reader(): _request_pipeline_cancel() return - if input_mode: - if key_event.key == "enter": - input_done.set() - return - if key_event.key == "escape": - input_chars.clear() - input_done.set() - return - if key_event.key == "backspace": - if input_chars: - input_chars.pop() - elif key_event.key == "paste": - if key_event.char: - input_chars.extend(key_event.char) - elif key_event.char and key_event.char.isprintable(): - input_chars.append(key_event.char) - tabs.set_status_message(f"✎ {''.join(input_chars)}█") - _live_update(tabs.render()) - continue - if key_event.key == "escape": interrupt_requested.set() if self._pipeline: @@ -3254,26 +3263,24 @@ async def key_reader(): pass async def _handle_esc_interrupt() -> bool: - """Handle ESC interrupt inline (no live.stop). Returns True if pipeline restarted.""" - nonlocal input_mode, interrupt_feedback + """Handle ESC interrupt prompt. Returns True if pipeline restarted.""" + nonlocal interrupt_feedback if self._pipeline: self._last_interrupt_paused = False self._pipeline.pause_agent_loops() + live_stopped = False try: - input_mode = True - input_chars.clear() - input_done.clear() - tabs.set_status_message("✎ █") + await _cancel_key_task() + tabs.set_status_message("✎") _live_update(tabs.render()) - nonlocal key_task - key_task = asyncio.create_task(key_reader()) - await input_done.wait() - - user_input = "".join(input_chars).strip() - input_mode = False + live.stop() + live_stopped = True + user_input = await self._read_pipeline_interrupt_input() + live.start() + live_stopped = False - if user_input: + if not user_input.is_empty: tabs.set_status_message(_("Judging your input...")) _live_update(tabs.render()) needs_restart, feedback = await self._handle_mid_pipeline_message(user_input, suppress_render=True) @@ -3287,6 +3294,8 @@ async def _handle_esc_interrupt() -> bool: else: tabs.set_status_message("") finally: + if live_stopped: + live.start() if self._pipeline and not getattr(self, "_last_interrupt_paused", False): self._pipeline.resume_agent_loops() interrupt_requested.clear() @@ -3529,9 +3538,6 @@ async def _render_parallel_tabs(self, event_stream, progress_bar_fn=None) -> boo stop_keys = asyncio.Event() interrupt_requested = asyncio.Event() - input_mode = False - input_chars: list[str] = [] - input_done = asyncio.Event() parent_task = asyncio.current_task() def _request_pipeline_cancel() -> None: @@ -3541,7 +3547,6 @@ def _request_pipeline_cancel() -> None: parent_task.cancel() async def key_reader(): - nonlocal input_mode loop = asyncio.get_running_loop() try: with RawInputCapture(use_cbreak=True) as cap: @@ -3554,27 +3559,6 @@ async def key_reader(): _request_pipeline_cancel() return - if input_mode: - if key_event.key == "enter": - input_done.set() - return - if key_event.key == "escape": - input_chars.clear() - input_done.set() - return - if key_event.key == "backspace": - if input_chars: - input_chars.pop() - elif key_event.key == "paste": - if key_event.char: - input_chars.extend(key_event.char) - elif key_event.char and key_event.char.isprintable(): - input_chars.append(key_event.char) - if tabs_renderer: - tabs_renderer.set_input_line("".join(input_chars)) - _update_live() - continue - if key_event.key == "escape": interrupt_requested.set() if self._pipeline: @@ -3631,7 +3615,7 @@ def _update_live(): key_task: asyncio.Task | None = None async def _prompt_child_permission(sub_id: str, inner: PermissionRequestEvent) -> None: - nonlocal input_mode, key_task, live + nonlocal key_task, live response_future = inner.response_future if response_future is None or response_future.done(): return @@ -3639,9 +3623,6 @@ async def _prompt_child_permission(sub_id: str, inner: PermissionRequestEvent) - allowed = False try: await _stop_key_reader() - input_mode = False - input_chars.clear() - input_done.clear() if tabs_renderer: tabs_renderer.set_input_line(None) live.stop() @@ -3671,22 +3652,22 @@ async def _prompt_child_permission(sub_id: str, inner: PermissionRequestEvent) - if self._pipeline: self._last_interrupt_paused = False self._pipeline.pause_agent_loops() + live_stopped = False try: - input_mode = True - input_chars.clear() - input_done.clear() + await _cancel_key_task() if tabs_renderer: - tabs_renderer.set_input_line("") + tabs_renderer.set_input_line("✎") _update_live() - key_task = asyncio.create_task(key_reader()) - await input_done.wait() - user_input = "".join(input_chars).strip() - input_mode = False + live.stop() + live_stopped = True + user_input = await self._read_pipeline_interrupt_input() + live.start() + live_stopped = False if tabs_renderer: tabs_renderer.set_input_line(None) - if user_input: + if not user_input.is_empty: if tabs_renderer: tabs_renderer.set_input_line(_("Judging your input...")) _update_live() @@ -3713,6 +3694,8 @@ async def _prompt_child_permission(sub_id: str, inner: PermissionRequestEvent) - for acc in accumulators.values(): acc.text_buffer += "\n" + feedback + "\n" finally: + if live_stopped: + live.start() if self._pipeline and not getattr(self, "_last_interrupt_paused", False): self._pipeline.resume_agent_loops() interrupt_requested.clear() @@ -4238,12 +4221,16 @@ def _terminal_pipeline_status(self, pipeline_cwd: str, session_id: str) -> str | logger.warning("Failed to inspect terminal pipeline sidecar: {}", exc) return None - def _persist_pipeline_visible_user_turn(self, user_input: str) -> None: + def _persist_pipeline_visible_user_turn(self, user_input: str | "PipelineUserInput") -> None: """Persist the user-visible pipeline prompt into the root session.""" - if not isinstance(user_input, str) or not user_input.strip(): + from iac_code.pipeline.engine.user_input import normalize_pipeline_user_input + + pipeline_input = normalize_pipeline_user_input(user_input) + if pipeline_input.is_empty: return + visible_input = pipeline_input.content if pipeline_input.has_images else pipeline_input.display_text try: - injected = self._agent_loop.context_manager.add_raw_message({"role": "user", "content": user_input}) + injected = self._agent_loop.context_manager.add_raw_message({"role": "user", "content": visible_input}) self._session_storage.append( self._original_cwd, self._session_id, diff --git a/src/iac_code/utils/image/processor.py b/src/iac_code/utils/image/processor.py index fa26ac63..5a9267cc 100644 --- a/src/iac_code/utils/image/processor.py +++ b/src/iac_code/utils/image/processor.py @@ -31,6 +31,7 @@ def process_user_input( ImageBlock( media_type=pc.media_type or "image/png", data=pc.content, + ref_id=pc.id, ) ) cursor = ref.end diff --git a/src/iac_code/utils/image/store.py b/src/iac_code/utils/image/store.py index 75e06372..c9fe89da 100644 --- a/src/iac_code/utils/image/store.py +++ b/src/iac_code/utils/image/store.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import hashlib import os import shutil import time @@ -13,6 +14,7 @@ IMAGE_STORE_DIR_NAME = "image-cache" MAX_STORED_IMAGE_PATHS = 200 +KNOWN_IMAGE_SUFFIXES = (".png", ".jpeg", ".jpg", ".gif", ".webp") # Concurrent REPL sessions each schedule background cleanup. To avoid # wiping a sibling session's still-in-use cache, only delete dirs whose # mtime is older than this threshold. Storing an image refreshes the @@ -57,6 +59,31 @@ def store(self, pc: PastedContent) -> str | None: self.cache_path(pc.id, str(path)) return str(path) + def store_block(self, block: object) -> str | None: + data = getattr(block, "data", "") + if not data: + return None + ensure_private_dir(_get_base_dir()) + d = ensure_private_dir(self._session_dir()) + media_type = getattr(block, "media_type", None) or "image/png" + ext = media_type.split("/")[-1] + digest = hashlib.sha256(str(data).encode()).hexdigest()[:32] + path = d / f"block-{digest}.{ext}" + if path.is_file(): + return str(path) + try: + decoded = base64.b64decode(data) + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + os.write(fd, decoded) + finally: + os.close(fd) + except FileExistsError: + return str(path) + except Exception: + return None + return str(path) + def cache_path(self, image_id: int, path: str) -> None: if image_id in self._paths: self._paths.move_to_end(image_id) @@ -65,7 +92,39 @@ def cache_path(self, image_id: int, path: str) -> None: self._paths.popitem(last=False) def get_path(self, image_id: int) -> str | None: - return self._paths.get(image_id) + cached = self._paths.get(image_id) + if cached: + return cached + discovered = self._discover_cached_path(image_id) + if discovered: + self.cache_path(image_id, discovered) + return discovered + + def _discover_cached_path(self, image_id: int) -> str | None: + session_dir = self._session_dir() + if not session_dir.exists(): + return None + for suffix in KNOWN_IMAGE_SUFFIXES: + path = session_dir / f"{image_id}{suffix}" + if path.is_file(): + return str(path) + for path in sorted(session_dir.glob(f"{image_id}.*")): + if path.is_file(): + return str(path) + return None + + def next_image_id(self) -> int: + image_ids = [image_id for image_id in self._paths if image_id > 0] + session_dir = self._session_dir() + if session_dir.exists(): + for path in session_dir.iterdir(): + if not path.is_file(): + continue + try: + image_ids.append(int(path.stem)) + except ValueError: + continue + return max(image_ids, default=0) + 1 def clear(self) -> None: self._paths.clear() diff --git a/templates/1-create-securitygroup-in-vpc.yml b/templates/1-create-securitygroup-in-vpc.yml new file mode 100644 index 00000000..98c9b3b2 --- /dev/null +++ b/templates/1-create-securitygroup-in-vpc.yml @@ -0,0 +1,32 @@ +ROSTemplateFormatVersion: '2015-09-01' +Metadata: + ALIYUN::ROS::Interface: + ParameterGroups: + - Parameters: + - VpcId + Label: + default: 网络配置 +Parameters: + VpcId: + Type: String + Label: + en: VPC + zh-cn: 专有网络 + AssociationProperty: ALIYUN::ECS::VPC::VPCId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} +Resources: + SecurityGroup: + Type: ALIYUN::ECS::SecurityGroup + Properties: + VpcId: !Ref VpcId + SecurityGroupName: app-security-group + Description: Application security group + SecurityGroupType: normal +Outputs: + SecurityGroupId: + Description: The ID of the created security group + Value: !GetAtt SecurityGroup.SecurityGroupId + Label: + en: Security Group ID + zh-cn: 安全组 ID diff --git a/templates/1-security-group-in-existing-vpc.yml b/templates/1-security-group-in-existing-vpc.yml new file mode 100644 index 00000000..9d15d30f --- /dev/null +++ b/templates/1-security-group-in-existing-vpc.yml @@ -0,0 +1,48 @@ +ROSTemplateFormatVersion: '2015-09-01' +Description: 在已有 VPC 中创建安全组 +Metadata: + ALIYUN::ROS::Interface: + ParameterGroups: + - Parameters: + - VpcId + Label: + default: 网络配置 +Parameters: + VpcId: + Type: String + Label: + en: VPC ID + zh-cn: 专有网络 + AssociationProperty: ALIYUN::ECS::VPC::VPCId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} +Resources: + SecurityGroup: + Type: ALIYUN::ECS::SecurityGroup + Properties: + VpcId: !Ref VpcId + SecurityGroupName: app-security-group + SecurityGroupIngress: + - IpProtocol: tcp + PortRange: 80/80 + SourceCidrIp: 0.0.0.0/0 + Priority: 1 + Description: HTTP + - IpProtocol: tcp + PortRange: 443/443 + SourceCidrIp: 0.0.0.0/0 + Priority: 1 + Description: HTTPS + SecurityGroupEgress: + - IpProtocol: all + PortRange: '-1/-1' + DestCidrIp: 0.0.0.0/0 + Priority: 1 + Description: Allow all outbound +Outputs: + SecurityGroupId: + Description: 创建的安全组 ID + Value: !Ref SecurityGroup + Label: + en: Security Group ID + zh-cn: 安全组 ID diff --git a/templates/1-single-vswitch.yml b/templates/1-single-vswitch.yml new file mode 100644 index 00000000..a9e4bfcc --- /dev/null +++ b/templates/1-single-vswitch.yml @@ -0,0 +1,54 @@ +ROSTemplateFormatVersion: '2015-09-01' +Metadata: + ALIYUN::ROS::Interface: + ParameterGroups: + - Parameters: + - VpcId + - ZoneId + - CidrBlock + Label: + default: 网络配置 +Parameters: + VpcId: + Type: String + Label: + en: VPC + zh-cn: 专有网络 + AssociationProperty: ALIYUN::ECS::VPC::VPCId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} + ZoneId: + Type: String + Label: + en: Zone ID + zh-cn: 可用区 + AssociationProperty: ALIYUN::ECS::ZoneId + AssociationPropertyMetadata: + RegionId: ${ALIYUN::Region} + CidrBlock: + Type: String + Label: + en: CIDR Block + zh-cn: 交换机网段 + Description: 交换机的 CIDR 网段,必须属于所属 VPC 的 CIDR 范围 +Resources: + VSwitch: + Type: ALIYUN::ECS::VSwitch + Properties: + VpcId: !Ref VpcId + ZoneId: !Ref ZoneId + CidrBlock: !Ref CidrBlock + VSwitchName: app-vswitch +Outputs: + VSwitchId: + Description: 创建的交换机 ID + Value: !GetAtt VSwitch.VSwitchId + Label: + en: VSwitch ID + zh-cn: 交换机 ID + CidrBlock: + Description: 交换机的 CIDR 网段 + Value: !GetAtt VSwitch.CidrBlock + Label: + en: CIDR Block + zh-cn: 交换机网段 diff --git a/templates/1-vswitch-in-existing-vpc.yml b/templates/1-vswitch-in-existing-vpc.yml index 3767aea8..b6641df5 100644 --- a/templates/1-vswitch-in-existing-vpc.yml +++ b/templates/1-vswitch-in-existing-vpc.yml @@ -1,46 +1,54 @@ ROSTemplateFormatVersion: '2015-09-01' -Description: 在已有 VPC 中新建 VSwitch +Description: 在已有 VPC 中创建一个新的 VSwitch Metadata: ALIYUN::ROS::Interface: ParameterGroups: - Parameters: - VpcId - ZoneId + - CidrBlock Label: default: 网络配置 Parameters: VpcId: Type: String + Description: 已有 VPC 的 ID Label: en: VPC ID - zh-cn: 专有网络 + zh-cn: 专有网络 ID AssociationProperty: ALIYUN::ECS::VPC::VPCId AssociationPropertyMetadata: RegionId: ${ALIYUN::Region} ZoneId: Type: String + Description: VSwitch 所在的可用区 Label: en: Zone ID zh-cn: 可用区 AssociationProperty: ALIYUN::ECS::ZoneId AssociationPropertyMetadata: RegionId: ${ALIYUN::Region} + CidrBlock: + Type: String + Description: VSwitch 的 CIDR 网段,如 172.16.0.0/24 + Label: + en: CIDR Block + zh-cn: 子网网段 + AssociationProperty: ALIYUN::VPC::VSwitch::CidrBlock + AssociationPropertyMetadata: + VpcId: ${VpcId} Resources: VSwitch: Type: ALIYUN::ECS::VSwitch Properties: VpcId: !Ref VpcId ZoneId: !Ref ZoneId - CidrBlock: 192.168.0.0/24 - VSwitchName: app-vswitch + CidrBlock: !Ref CidrBlock + VSwitchName: vswitch-new Outputs: VSwitchId: + Description: 创建的 VSwitch ID + Value: !Ref VSwitch Label: en: VSwitch ID zh-cn: 交换机 ID - Value: !GetAtt VSwitch.VSwitchId - CidrBlock: - Label: - en: VSwitch CIDR Block - zh-cn: 交换机网段 - Value: !GetAtt VSwitch.CidrBlock diff --git a/tests/a2a/test_app.py b/tests/a2a/test_app.py index 42ffea9b..dc4c4d3a 100644 --- a/tests/a2a/test_app.py +++ b/tests/a2a/test_app.py @@ -850,7 +850,7 @@ def should_switch_to_normal(self, data: dict) -> bool: # noqa: ARG002 assert fake_pipeline.prompts == ["选择一个已有vpc,创建一个vswitch"] -def test_pipeline_streaming_workspace_error_returns_failed_task_event(monkeypatch, tmp_path: Path) -> None: +def test_pipeline_streaming_workspace_error_returns_request_error(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") allowed = tmp_path / "allowed" outside = tmp_path / "outside" @@ -884,8 +884,10 @@ def test_pipeline_streaming_workspace_error_returns_failed_task_event(monkeypatc assert response.status_code == 200 assert "Agent should enqueue Task before TaskStatusUpdateEvent event" not in body - assert "workspace" in body.lower() - assert "failed" in body.lower() + data = response.json() + assert data["error"]["code"] == -32602 + assert data["error"]["message"] == "Invalid A2A workspace metadata." + assert data["error"]["data"][0]["reason"] == "INVALID_PARAMS" def test_follow_up_message_through_sdk_route_updates_existing_task(monkeypatch, tmp_path) -> None: diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index a55cd425..8423e361 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -4,6 +4,7 @@ import pytest from a2a.types import TaskStatusUpdateEvent +from a2a.utils.errors import InvalidParamsError from google.protobuf.json_format import MessageToDict from iac_code.a2a.executor import IacCodeA2AExecutor @@ -13,6 +14,8 @@ from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session from iac_code.a2a.task_store import A2ATaskStore +from iac_code.agent.message import ImageBlock +from iac_code.pipeline.engine.user_input import PipelineUserInput from iac_code.types.stream_events import PermissionRequestEvent, TextDeltaEvent, ToolResultEvent from .fakes import FakeAgentLoop, FakeEventQueue, FakeRequestContext, FakeRuntime, pending_future @@ -22,6 +25,14 @@ def dump(event): return MessageToDict(event, preserving_proto_field_name=False) +def _image_only_pipeline_input() -> PipelineUserInput: + return PipelineUserInput( + content=[ImageBlock(media_type="image/png", data="aGVsbG8=")], + display_text="[Image input]", + has_images=True, + ) + + @pytest.fixture(autouse=True) def default_normal_mode(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("IAC_CODE_MODE", raising=False) @@ -388,8 +399,18 @@ class SpyPipelineExecutor: def __init__(self, **kwargs): calls.append(("init", kwargs)) - async def execute(self, *, context, event_queue, task, task_id, context_id, cwd, prompt): - calls.append(("execute", {"task_id": task_id, "context_id": context_id, "cwd": cwd, "prompt": prompt})) + async def execute(self, *, context, event_queue, task, task_id, context_id, cwd, pipeline_input): + calls.append( + ( + "execute", + { + "task_id": task_id, + "context_id": context_id, + "cwd": cwd, + "pipeline_input": pipeline_input, + }, + ) + ) monkeypatch.setattr("iac_code.a2a.executor.IacCodeA2APipelineExecutor", SpyPipelineExecutor) @@ -400,7 +421,12 @@ async def execute(self, *, context, event_queue, task, task_id, context_id, cwd, assert calls[-1] == ( "execute", - {"task_id": "task-1", "context_id": "ctx-1", "cwd": str(tmp_path), "prompt": "hello"}, + { + "task_id": "task-1", + "context_id": "ctx-1", + "cwd": str(tmp_path), + "pipeline_input": PipelineUserInput(content="hello", display_text="hello", has_images=False), + }, ) @@ -435,8 +461,18 @@ class SpyPipelineExecutor: def __init__(self, **kwargs): calls.append(("init", kwargs)) - async def execute(self, *, context, event_queue, task, task_id, context_id, cwd, prompt): - calls.append(("execute", {"task_id": task_id, "context_id": context_id, "cwd": cwd, "prompt": prompt})) + async def execute(self, *, context, event_queue, task, task_id, context_id, cwd, pipeline_input): + calls.append( + ( + "execute", + { + "task_id": task_id, + "context_id": context_id, + "cwd": cwd, + "pipeline_input": pipeline_input, + }, + ) + ) monkeypatch.setattr("iac_code.a2a.executor.IacCodeA2APipelineExecutor", SpyPipelineExecutor) @@ -455,10 +491,103 @@ async def execute(self, *, context, event_queue, task, task_id, context_id, cwd, assert calls[-1] == ( "execute", - {"task_id": "task-1", "context_id": "ctx-1", "cwd": str(tmp_path), "prompt": "继续"}, + { + "task_id": "task-1", + "context_id": "ctx-1", + "cwd": str(tmp_path), + "pipeline_input": PipelineUserInput(content="继续", display_text="继续", has_images=False), + }, ) +@pytest.mark.asyncio +async def test_pipeline_mode_accepts_image_only_input(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + pipeline_input = _image_only_pipeline_input() + calls = [] + + class CapturingPipelineExecutor: + def __init__(self, **kwargs): + pass + + async def execute(self, **kwargs): + calls.append(kwargs) + + monkeypatch.setattr("iac_code.a2a.executor.IacCodeA2APipelineExecutor", CapturingPipelineExecutor) + monkeypatch.setattr( + IacCodeA2AExecutor, + "_pipeline_input_from_context", + lambda self, context, *, cwd: pipeline_input, + ) + monkeypatch.setattr("iac_code.a2a.executor.is_model_multimodal", lambda *args, **kwargs: True) + + 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) + + assert calls + assert calls[0]["pipeline_input"] == pipeline_input + states = [dump(event)["status"]["state"] for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + assert "TASK_STATE_FAILED" not in states + + +@pytest.mark.asyncio +async def test_pipeline_mode_image_input_checks_provider_context( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + monkeypatch.setattr( + IacCodeA2AExecutor, + "_pipeline_input_from_context", + lambda self, context, *, cwd: _image_only_pipeline_input(), + ) + seen = {} + + def fake_is_model_multimodal(model, *, provider_key=None, base_url=None, api_key=None): + seen.update( + { + "model": model, + "provider_key": provider_key, + "base_url": base_url, + "api_key": api_key, + } + ) + return False + + monkeypatch.setattr("iac_code.a2a.executor.get_active_provider_key", lambda: "openapi_compatible") + monkeypatch.setattr( + "iac_code.a2a.executor.get_provider_config", + lambda provider_key: {"keyName": provider_key, "apiBase": "https://example.test/v1"}, + ) + monkeypatch.setattr( + "iac_code.a2a.executor.load_credentials", + lambda model=None: {"openapi_compatible": "test-key"}, + ) + monkeypatch.setattr("iac_code.a2a.executor.is_model_multimodal", fake_is_model_multimodal) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="custom-vl") + + queue = FakeEventQueue() + with pytest.raises(InvalidParamsError, match="Current model custom-vl does not support image input"): + await executor.execute( + FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), + queue, + ) + + assert seen == { + "model": "custom-vl", + "provider_key": "openapi_compatible", + "base_url": "https://example.test/v1", + "api_key": "test-key", + } + assert not [event for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + with pytest.raises(ValueError, match="A2A task not found"): + await store.get_task_record("task-1") + + @pytest.mark.asyncio async def test_executor_empty_prompt_takes_precedence_over_pipeline_mode( monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -475,11 +604,11 @@ def fail_if_called(options): # noqa: ARG001 queue = FakeEventQueue() context = FakeRequestContext(text=" ", metadata={"iac_code": {"cwd": str(tmp_path)}}) - await executor.execute(context, queue) - - dumped = dump(queue.events[-1]) - assert dumped["status"]["state"] == "TASK_STATE_FAILED" - assert dumped["status"]["message"]["parts"][0]["text"] == "A2A server currently accepts text input only." + with pytest.raises(InvalidParamsError, match="A2A server received empty input"): + await executor.execute(context, queue) + assert not [event for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + with pytest.raises(ValueError, match="A2A task not found"): + await store.get_task_record("task-1") @pytest.mark.asyncio @@ -846,6 +975,80 @@ async def execute(self, **kwargs) -> None: assert any(getattr(message, "content", "") == "[Pipeline Handoff Context]" for message in seen_resume[0]) +@pytest.mark.asyncio +async def test_pipeline_handoff_image_request_uses_normal_manifest_prompt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from a2a.types import Message, Part, Role + + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.agent.message import Message as AgentMessage + from iac_code.services.session_storage import SessionStorage + + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + config_dir = tmp_path / "config" + config_dir.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + cwd = tmp_path / "ws" + cwd.mkdir() + session_id = "session-handoff" + context_id = "ctx-handoff" + persistence = A2APersistenceStore(tmp_path / "a2a") + persistence.save_context(A2AContextSnapshot(context_id=context_id, session_id=session_id, cwd=str(cwd))) + A2APipelineSnapshotStore(a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id)).save( + {"normalHandoff": {"action": "switch_to_normal", "targetMode": "normal", "summary": "handoff"}} + ) + SessionStorage().append(str(cwd), session_id, AgentMessage(role="user", content="handoff")) + + def fail_pipeline_input(*args, **kwargs): + raise AssertionError("normal handoff must not build PipelineUserInput") + + monkeypatch.setattr(IacCodeA2AExecutor, "_pipeline_input_from_context", fail_pipeline_input) + loop = FakeAgentLoop([TextDeltaEvent(text="normal-ok")]) + monkeypatch.setattr( + "iac_code.a2a.executor.create_agent_runtime", + lambda options: FakeRuntime(agent_loop=loop, session_id=options.session_id), + ) + + class FailingPipelineExecutor: + def __init__(self, **kwargs) -> None: + pass + + async def execute(self, **kwargs) -> None: + raise AssertionError("pipeline executor should not be used after normal handoff") + + monkeypatch.setattr("iac_code.a2a.executor.IacCodeA2APipelineExecutor", FailingPipelineExecutor) + + context = FakeRequestContext( + task_id="task-followup", + context_id=context_id, + text="", + metadata={"iac_code": {"cwd": str(cwd)}}, + ) + context.message = Message( + role=Role.ROLE_USER, + parts=[Part(raw=b"\x89PNG\r\n\x1a\nimage", media_type="image/png", filename="diagram.png")], + message_id="msg-1", + ) + + executor = IacCodeA2AExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence), + model="qwen3.6-plus", + ) + await executor.execute( + context, + FakeEventQueue(), + ) + + assert loop.prompts + assert "A2A multimodal attachment:" in loop.prompts[0] + assert "mediaType=image/png" in loop.prompts[0] + assert "[Image input]" not in loop.prompts[0] + + @pytest.mark.asyncio async def test_pipeline_handoff_context_is_backfilled_from_snapshot_when_session_missing( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/a2a/test_parts.py b/tests/a2a/test_parts.py index c0f7212f..1d8deeab 100644 --- a/tests/a2a/test_parts.py +++ b/tests/a2a/test_parts.py @@ -1,12 +1,17 @@ from __future__ import annotations import base64 +import io +from types import SimpleNamespace import pytest from a2a.types import Part from google.protobuf.struct_pb2 import Value +from PIL import Image from iac_code.a2a import parts +from iac_code.a2a.parts import parts_to_pipeline_input +from iac_code.agent.message import ImageBlock, TextBlock def _data_part(value: dict[str, object]) -> Part: @@ -192,3 +197,134 @@ def test_message_parts_join_non_empty_values(tmp_path) -> None: assert parts.parts_to_prompt([Part(text="first"), Part(text=""), Part(text="second")], cwd=tmp_path) == ( "first\nsecond" ) + + +def _resize_spy(monkeypatch, *, output: bytes, media_type: str = "image/webp") -> list[bytes]: + calls: list[bytes] = [] + + def fake_resize(content: bytes): + calls.append(content) + return SimpleNamespace(data=output, media_type=media_type) + + monkeypatch.setattr("iac_code.a2a.parts.maybe_resize_and_downsample", fake_resize) + return calls + + +def _tiny_bmp_bytes() -> bytes: + buf = io.BytesIO() + Image.new("RGB", (1, 1), color=(255, 0, 0)).save(buf, format="BMP") + return buf.getvalue() + + +def _tiny_png_bytes() -> bytes: + buf = io.BytesIO() + Image.new("RGB", (1, 1), color=(0, 255, 0)).save(buf, format="PNG") + return buf.getvalue() + + +def test_parts_to_pipeline_input_converts_raw_image(monkeypatch, tmp_path) -> None: + raw = b"fake png bytes" + resized = b"resized raw image" + calls = _resize_spy(monkeypatch, output=resized, media_type="image/webp") + + value = parts_to_pipeline_input([Part(raw=raw, media_type="image/png")], cwd=tmp_path) + + assert calls == [raw] + assert value.has_images is True + assert value.display_text == "[Image input]" + assert value.content == [ImageBlock(media_type="image/webp", data=base64.b64encode(resized).decode("ascii"))] + + +def test_parts_to_pipeline_input_preserves_text_plus_image_order(monkeypatch, tmp_path) -> None: + raw = b"fake jpeg bytes" + resized = b"resized jpeg bytes" + calls = _resize_spy(monkeypatch, output=resized, media_type="image/jpeg") + + value = parts_to_pipeline_input( + [ + Part(text="inspect this", media_type="text/plain"), + Part(raw=raw, media_type="image/jpeg"), + ], + cwd=tmp_path, + ) + + assert calls == [raw] + assert value.display_text == "inspect this" + assert value.content == [ + TextBlock(text="inspect this"), + ImageBlock(media_type="image/jpeg", data=base64.b64encode(resized).decode("ascii")), + ] + + +def test_parts_to_pipeline_input_converts_base64_data_image(monkeypatch, tmp_path) -> None: + raw = b"fake data image" + resized = b"resized data image" + encoded = base64.b64encode(raw).decode("ascii") + calls = _resize_spy(monkeypatch, output=resized, media_type="image/png") + + value = parts_to_pipeline_input([_binary_data_part({"bytes": encoded}, media_type="image/png")], cwd=tmp_path) + + assert calls == [raw] + assert value.content == [ImageBlock(media_type="image/png", data=base64.b64encode(resized).decode("ascii"))] + + +def test_parts_to_pipeline_input_converts_safe_file_url_image(monkeypatch, tmp_path) -> None: + raw = b"file image bytes" + resized = b"resized file image" + source = tmp_path / "diagram.png" + source.write_bytes(raw) + calls = _resize_spy(monkeypatch, output=resized, media_type="image/png") + + value = parts_to_pipeline_input([Part(url=source.as_uri(), media_type="image/png")], cwd=tmp_path) + + assert calls == [raw] + assert value.content == [ImageBlock(media_type="image/png", data=base64.b64encode(resized).decode("ascii"))] + + +def test_parts_to_pipeline_input_uses_real_resizer_for_valid_image_bytes(tmp_path) -> None: + raw = _tiny_bmp_bytes() + + value = parts_to_pipeline_input([Part(raw=raw, media_type="image/png")], cwd=tmp_path) + + assert isinstance(value.content, list) + block = value.content[0] + assert isinstance(block, ImageBlock) + assert block.media_type == "image/png" + assert base64.b64decode(block.data).startswith(b"\x89PNG\r\n\x1a\n") + + +def test_parts_to_pipeline_input_accepts_tiny_png_without_monkeypatch(tmp_path) -> None: + raw = _tiny_png_bytes() + + value = parts_to_pipeline_input([Part(raw=raw, media_type="image/png")], cwd=tmp_path) + + assert isinstance(value.content, list) + block = value.content[0] + assert isinstance(block, ImageBlock) + assert block.media_type == "image/png" + assert base64.b64decode(block.data).startswith(b"\x89PNG\r\n\x1a\n") + + +def test_parts_to_pipeline_input_rejects_unsafe_file_url_image(tmp_path) -> None: + outside = tmp_path.parent / "outside-diagram.png" + outside.write_bytes(b"outside") + + with pytest.raises(ValueError, match="outside the allowed workspace"): + parts_to_pipeline_input([Part(url=outside.as_uri(), media_type="image/png")], cwd=tmp_path) + + +def test_parts_to_pipeline_input_rejects_invalid_base64_data_image(tmp_path) -> None: + with pytest.raises(ValueError, match="valid base64"): + parts_to_pipeline_input([_binary_data_part({"bytes": "not-base64!"}, media_type="image/png")], cwd=tmp_path) + + +def test_parts_to_pipeline_input_rejects_oversized_raw_image(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("iac_code.a2a.parts.MAX_BINARY_INLINE_BYTES", 3) + + with pytest.raises(ValueError, match="too large"): + parts_to_pipeline_input([Part(raw=b"abcd", media_type="image/png")], cwd=tmp_path) + + +def test_parts_to_pipeline_input_rejects_audio_as_true_image(tmp_path) -> None: + with pytest.raises(ValueError, match="unsupported image media type"): + parts_to_pipeline_input([Part(raw=b"audio", media_type="audio/wav")], cwd=tmp_path) diff --git a/tests/a2a/test_pipeline_debugger_script.py b/tests/a2a/test_pipeline_debugger_script.py index aa7549f0..c8761031 100644 --- a/tests/a2a/test_pipeline_debugger_script.py +++ b/tests/a2a/test_pipeline_debugger_script.py @@ -75,7 +75,7 @@ def serve_handler(handler_cls: type[BaseHTTPRequestHandler]) -> Iterator[str]: thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: - host, port = server.server_address + host, port = server.server_address[:2] yield f"http://{host}:{port}" finally: server.shutdown() @@ -100,7 +100,7 @@ def start_debugger_server(debugger, *, default_cwd: str = "/workspace/demo"): server = debugger.create_server(config) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() - host, port = server.server_address + host, port = server.server_address[:2] class RunningServer: url = f"http://{host}:{port}" @@ -124,7 +124,7 @@ def start_logged_debugger_server(debugger, *, log_dir: Path, default_cwd: str = server = debugger.create_server(config) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() - host, port = server.server_address + host, port = server.server_address[:2] class RunningServer: url = f"http://{host}:{port}" @@ -224,6 +224,61 @@ def test_build_message_stream_payload_uses_a2a_v1_method_and_cwd_metadata() -> N assert payload["params"]["configuration"] == {"acceptedOutputModes": ["text/plain"]} +def test_build_message_stream_payload_adds_image_data_parts() -> None: + debugger = load_debugger_module() + + payload = debugger.build_message_stream_payload( + cwd="/workspace/demo", + prompt="inspect this topology", + context_id="ctx-demo", + task_id="task-demo", + request_id="req-1", + message_id="msg-1", + images=[ + { + "filename": "topology.png", + "mediaType": "image/png", + "bytes": "iVBORw0KGgo=", + } + ], + ) + + assert payload["params"]["message"]["parts"] == [ + {"text": "inspect this topology"}, + { + "data": {"filename": "topology.png", "bytes": "iVBORw0KGgo="}, + "mediaType": "image/png", + }, + ] + + +def test_build_message_stream_payload_allows_image_only_parts() -> None: + debugger = load_debugger_module() + + payload = debugger.build_message_stream_payload( + cwd="/workspace/demo", + prompt="", + context_id="ctx-demo", + task_id="task-demo", + request_id="req-1", + message_id="msg-1", + images=[ + { + "filename": "topology.png", + "mediaType": "image/png", + "bytes": "iVBORw0KGgo=", + } + ], + ) + + assert payload["params"]["message"]["parts"] == [ + { + "data": {"filename": "topology.png", "bytes": "iVBORw0KGgo="}, + "mediaType": "image/png", + }, + ] + + def test_build_message_stream_payload_omits_blank_context_id() -> None: debugger = load_debugger_module() @@ -357,6 +412,8 @@ def test_index_html_contains_debugger_controls_and_raw_panels(tmp_path: Path) -> 'id="context-id"', 'id="task-id"', 'id="prompt"', + 'id="image-input"', + 'id="image-summary"', 'id="stream-button"', 'id="fetch-state-button"', 'id="cancel-button"', @@ -1915,6 +1972,30 @@ 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.", + }, + } + ).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_message_stream_route_forwards_sse_and_uses_stream_payload() -> None: debugger = load_debugger_module() SseTargetHandler.requests = [] @@ -1946,6 +2027,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 = [] @@ -2003,6 +2159,33 @@ 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 + 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 + + 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_executor.py b/tests/a2a/test_pipeline_executor.py index 76ba9155..d1acbc2c 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -17,8 +17,11 @@ 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 @@ -31,6 +34,18 @@ 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 @@ -47,14 +62,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 @@ -62,7 +77,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: @@ -3033,7 +3048,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, ) @@ -3126,7 +3141,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, ) @@ -4374,3 +4389,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_transport_dispatcher.py b/tests/a2a/test_transport_dispatcher.py index 5e9ae89b..7b4dd76a 100644 --- a/tests/a2a/test_transport_dispatcher.py +++ b/tests/a2a/test_transport_dispatcher.py @@ -1,4 +1,5 @@ import asyncio +import base64 from types import SimpleNamespace import pytest @@ -80,6 +81,54 @@ async def test_dispatcher_stream_yields_events(monkeypatch, tmp_path) -> None: await components.aclose() +@pytest.mark.asyncio +async def test_dispatcher_rejects_pipeline_image_before_executor_runs(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + monkeypatch.setattr( + "iac_code.a2a.parts.maybe_resize_and_downsample", + lambda raw: SimpleNamespace(data=raw, media_type="image/png"), + ) + monkeypatch.setattr("iac_code.a2a.executor.is_model_multimodal", lambda *args, **kwargs: False) + + async def fail_if_called(*args, **kwargs): + raise AssertionError("executor should not run for invalid image input") + + monkeypatch.setattr("iac_code.a2a.executor.IacCodeA2AExecutor.execute", fail_if_called) + components = create_runtime_components(model="text-only-model", host="127.0.0.1", port=41242) + dispatcher = A2AJsonRpcDispatcher(components) + + response = await dispatcher.dispatch( + { + "jsonrpc": "2.0", + "id": "image-invalid", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-image-invalid", + "contextId": "ctx-image-invalid", + "role": "ROLE_USER", + "parts": [ + { + "data": { + "filename": "initial.png", + "bytes": base64.b64encode(b"fake image").decode("ascii"), + }, + "mediaType": "image/png", + } + ], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + ) + + assert response["id"] == "image-invalid" + assert response["error"]["code"] == -32602 + assert response["error"]["message"] == "Current model text-only-model does not support image input." + await components.aclose() + + @pytest.mark.asyncio async def test_dispatcher_routes_second_pipeline_stream_as_interrupt(monkeypatch, tmp_path) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") diff --git a/tests/a2a_e2e/test_run_recovery_scenarios.py b/tests/a2a_e2e/test_run_recovery_scenarios.py index cfeb80bf..d5ec6678 100644 --- a/tests/a2a_e2e/test_run_recovery_scenarios.py +++ b/tests/a2a_e2e/test_run_recovery_scenarios.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import importlib.util import json import sys @@ -67,6 +68,111 @@ def test_normal_running_recovery_prompt_ignores_continue() -> None: assert "更早的方案选择消息" in runner.DEFAULT_NORMAL_RUNNING_RECOVERY_PROMPT +def test_text_image_fixture_store_writes_png_and_manifest(tmp_path: Path) -> None: + runner = _load_runner() + store = runner.TextImageFixtureStore(tmp_path / "image-fixtures") + + part = store.part("runtime-only", runner.DEFAULT_INITIAL_PROMPT) + + assert part["filename"] == "runtime-only.png" + assert part["mediaType"] == "image/png" + assert base64.b64decode(part["bytes"]).startswith(b"\x89PNG\r\n\x1a\n") + assert (tmp_path / "image-fixtures" / "runtime-only.png").is_file() + manifest = json.loads((tmp_path / "image-fixtures" / "manifest.json").read_text(encoding="utf-8")) + assert manifest["runtime-only"]["text"] == runner.DEFAULT_INITIAL_PROMPT + assert manifest["runtime-only"]["mediaType"] == "image/png" + assert manifest["runtime-only"]["source"] == "generated" + + +def test_static_text_image_fixtures_cover_fixed_image_prompts() -> None: + runner = _load_runner() + manifest = json.loads((runner.STATIC_TEXT_IMAGE_FIXTURE_ROOT / "manifest.json").read_text(encoding="utf-8")) + + assert set(manifest) == set(runner.STATIC_TEXT_IMAGE_FIXTURES) + for key, text in runner.STATIC_TEXT_IMAGE_FIXTURES.items(): + entry = manifest[key] + fixture_path = runner.STATIC_TEXT_IMAGE_FIXTURE_ROOT / entry["filename"] + assert entry["text"] == text + assert entry["mediaType"] == "image/png" + assert fixture_path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + +def test_text_image_fixture_store_prefers_static_fixture(monkeypatch, tmp_path: Path) -> None: + runner = _load_runner() + store = runner.TextImageFixtureStore(tmp_path / "image-fixtures") + static_manifest = json.loads((runner.STATIC_TEXT_IMAGE_FIXTURE_ROOT / "manifest.json").read_text(encoding="utf-8")) + + def fail_render(_text: str) -> bytes: + raise AssertionError("static fixtures should avoid runtime image rendering") + + monkeypatch.setattr(runner, "_render_text_png", fail_render) + + part = store.part("initial", runner.STATIC_TEXT_IMAGE_FIXTURES["initial"]) + + static_path = runner.STATIC_TEXT_IMAGE_FIXTURE_ROOT / static_manifest["initial"]["filename"] + assert part["filename"] == static_path.name + assert part["mediaType"] == "image/png" + assert base64.b64decode(part["bytes"]) == static_path.read_bytes() + manifest = json.loads((tmp_path / "image-fixtures" / "manifest.json").read_text(encoding="utf-8")) + assert manifest["initial"]["source"] == "static" + assert manifest["initial"]["path"] == str(static_path) + + +def test_scenario_harness_stream_passes_image_parts(monkeypatch, tmp_path: Path) -> None: + runner = _load_runner() + captured: dict[str, object] = {} + + args = SimpleNamespace( + server_cwd=str(tmp_path), + cwd="", + port=0, + host="127.0.0.1", + no_auto_approve_permissions=False, + provider="", + model="", + api_base="", + deterministic=False, + fault_at="", + stream_timeout=1, + run_dir=str(tmp_path / "run"), + run_root=str(tmp_path / "runs"), + python=sys.executable, + leave_server_running=False, + ) + harness = runner.ScenarioHarness(args, scenario="image-initial") + image = {"filename": "initial.png", "mediaType": "image/png", "bytes": "iVBORw0KGgo="} + + def fake_stream_message(**kwargs): + captured.update(kwargs) + return runner.StreamSummary( + name=kwargs["name"], + prompt=kwargs["prompt"], + request_task_id=kwargs["task_id"], + task_id="task-1", + context_id="ctx-1", + ) + + monkeypatch.setattr(runner, "stream_message", fake_stream_message) + + harness.stream(prompt=runner.IMAGE_TEXT_PROMPT, name="01-image", context_id="", task_id="", images=[image]) + + assert captured["images"] == [image] + + +def test_image_recovery_scenarios_are_registered() -> None: + runner = _load_runner() + + for scenario in [ + "image-initial", + "image-ask-waiting", + "image-selection-waiting", + "image-normal-handoff", + "image-interrupt", + ]: + assert scenario in runner._SCENARIOS + assert scenario in runner._REAL_CLOUD_SCENARIOS + + def test_answer_intervening_ask_inputs_reaches_selection(tmp_path: Path) -> None: runner = _load_runner() initial = runner.StreamSummary( @@ -773,6 +879,167 @@ def fake_run_with_harness(_args, _scenario, callback): assert harness.checks["ROS second stack retained"] is True +def test_rollback_step5_cleanup_recovery_uses_tool_safe_recovery_prompt(monkeypatch, tmp_path: Path) -> None: + runner = _load_runner() + fake_harnesses = [] + + class FakeStream: + def __init__(self, summary: object, events: list[dict] | None = None) -> None: + self.summary = summary + self.name = summary.name + self.events = events or [] + + def wait_for(self, *_args, **_kwargs): + return None + + def join(self, timeout: float): + return self.summary + + class FakeHarness: + def __init__(self) -> None: + self.args = SimpleNamespace(stream_timeout=1, event_timeout=1) + self.run_dir = tmp_path + self.server_env = {} + self.cwd = str(tmp_path) + self.context_id = "ctx-1" + self.pipeline_task_id = "task-1" + self.checks: dict[str, bool] = {} + self.notes: list[str] = [] + self.summaries = {} + self.snapshots = {} + self.stream_calls: list[dict] = [] + + def stream(self, *, prompt: str, name: str, task_id: str | None = None, **_kwargs): + self.stream_calls.append({"prompt": prompt, "name": name, "task_id": task_id}) + is_initial = name == "01-initial" + summary = runner.StreamSummary( + name=name, + prompt=prompt, + request_task_id=self.pipeline_task_id if task_id is None else task_id, + context_id=self.context_id, + task_id="normal-task" if task_id == "" else self.pipeline_task_id, + status_states=["TASK_STATE_INPUT_REQUIRED"] if is_initial else ["TASK_STATE_COMPLETED"], + pipeline_event_types=["input_required"] if is_initial else ["pipeline_completed"], + last_input_required_step_id="confirm_and_select" if is_initial else "", + normal_handoff_ready=True, + text="done", + ) + self.summaries[name] = summary + return summary + + def start_stream(self, *, prompt: str, name: str, task_id: str | None = None, **_kwargs): + summary = runner.StreamSummary( + name=name, + prompt=prompt, + request_task_id=self.pipeline_task_id if task_id is None else task_id, + context_id=self.context_id, + task_id="normal-task" if task_id == "" else self.pipeline_task_id, + status_states=["TASK_STATE_COMPLETED"], + pipeline_event_types=["pipeline_completed"], + normal_handoff_ready=True, + text="done", + ) + self.summaries[name] = summary + events = [] + if name == "04-select-second-stack": + events.append( + _stack_current_changed_event( + action="CreateStack", + stack_id="stack-2", + status="CREATE_COMPLETE", + is_success=True, + ) + ) + return FakeStream(summary, events=events) + + def fetch_state(self, name: str): + snapshot = { + "snapshot": { + "status": "completed", + "cleanup": { + "status": "completed", + "resources": [ + { + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-1", + "regionId": "cn-hangzhou", + "cleanupStatus": "completed", + "stackStatus": "DELETE_COMPLETE", + } + ], + }, + "stacks": { + "current": {"stackId": "stack-2", "regionId": "cn-hangzhou", "current": True}, + "byId": {"stack-2": {"stackId": "stack-2", "current": True}}, + }, + } + } + self.snapshots[name] = snapshot + return snapshot + + def kill9_and_restart(self) -> None: + self.notes.append("restarted") + + def fake_run_with_harness(_args, _scenario, callback): + harness = FakeHarness() + fake_harnesses.append(harness) + callback(harness) + return 0 if all(harness.checks.values()) else 1 + + cleanup_ledger_items = [ + { + "provider": "ros", + "resource_type": "stack", + "resource_id": "stack-1", + "region_id": "cn-hangzhou", + "cleanup_required": True, + } + ] + + monkeypatch.setattr(runner, "_run_with_harness", fake_run_with_harness) + monkeypatch.setattr(runner, "_answer_intervening_ask_inputs", lambda _h, summary, **_kwargs: summary) + monkeypatch.setattr(runner, "_wait_for_created_stack", lambda *_args, **_kwargs: "stack-1") + monkeypatch.setattr(runner, "_wait_any", lambda *_args, **_kwargs: None) + monkeypatch.setattr(runner, "_finish_pipeline_after_possible_input", lambda *_args, **_kwargs: None) + monkeypatch.setattr(runner, "_wait_for_cleanup_started", lambda *_args, **_kwargs: None) + monkeypatch.setattr(runner, "_join_after_kill", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + runner, + "_events_file_has_cleanup_event", + lambda *_args, **_kwargs: True, + ) + monkeypatch.setattr( + runner, + "_cleanup_ledger_items", + lambda _h, key: cleanup_ledger_items if key == "cleanup_resources" else [], + ) + monkeypatch.setattr( + runner, + "_capture_ros_stack_states", + lambda _h, stack_ids, name: { + "stack-1": {"status": "DELETE_COMPLETE"}, + "stack-2": {"status": "CREATE_COMPLETE"}, + }, + ) + + args = SimpleNamespace( + event_timeout=1, + initial_prompt=runner.DEFAULT_INITIAL_PROMPT, + selection_prompt=runner.DEFAULT_SELECTION_PROMPT, + normal_followup_prompt=runner.DEFAULT_NORMAL_FOLLOWUP_PROMPT, + ) + + assert runner.run_rollback_step5_cleanup_recovery(args, "rollback-step5-cleanup-recovery") == 0 + recovery_prompt = next( + call["prompt"] for call in fake_harnesses[0].stream_calls if call["name"] == "06-cleanup-after-restart" + ) + assert recovery_prompt != runner.CONTINUE_PROMPT + assert "不要调用任何工具" in recovery_prompt + assert "不要查询" in recovery_prompt + assert "不要删除" in recovery_prompt + + def test_rollback_step5_cleanup_flow_fails_when_any_cleanup_stack_is_left(monkeypatch, tmp_path: Path) -> None: runner = _load_runner() @@ -1090,7 +1357,7 @@ def fake_run_with_harness(_args, _scenario, callback): "task_id": "", } assert harness.stream_calls[-1] == { - "prompt": runner.CONTINUE_PROMPT, + "prompt": runner.CLEANUP_RECOVERY_PROMPT, "name": "06-cleanup-after-restart", "task_id": "", } diff --git a/tests/pipeline/engine/test_interrupt.py b/tests/pipeline/engine/test_interrupt.py index ee46e213..565ecefe 100644 --- a/tests/pipeline/engine/test_interrupt.py +++ b/tests/pipeline/engine/test_interrupt.py @@ -6,6 +6,9 @@ import pytest +from iac_code.agent.message import ImageBlock, TextBlock +from iac_code.pipeline.engine.user_input import PipelineUserInput + class TestInterruptVerdict: def test_verdict_creation(self): @@ -101,6 +104,49 @@ async def test_judge_parses_valid_response(self): assert verdict.action == "hard_interrupt" assert verdict.rollback_target == "intent_parsing" + @pytest.mark.asyncio + async def test_judge_sends_image_blocks_to_provider(self): + from iac_code.pipeline.engine.interrupt import InterruptController + + captured = {} + + class ProviderManager: + async def complete(self, *, messages, system, tools=None, max_tokens=8192, cache_policy="default"): + captured["messages"] = messages + return type( + "Response", + (), + { + "text": ( + '{"action":"supplement","reason":"image updates current step",' + '"rollback_target":null,"candidate_scope":null,' + '"supplement_target":null,"rollback_context":null}' + ) + }, + )() + + controller = InterruptController( + ProviderManager(), + lambda: {"pipeline_name": "selling", "steps": []}, + ) + image = ImageBlock(media_type="image/png", data="aGVsbG8=") + verdict = await controller.judge( + PipelineUserInput( + content=[TextBlock(text="see diagram"), image], + display_text="see diagram", + has_images=True, + ) + ) + + assert verdict.action == "supplement" + message = captured["messages"][0] + assert isinstance(message.content, list) + assert message.content[0].type == "text" + assert "用户同时提供了图片输入" in (message.content[0].text or "") + assert message.content[1].type == "image" + assert message.content[1].media_type == "image/png" + assert message.content[1].data == "aGVsbG8=" + @pytest.mark.asyncio async def test_judge_invalid_json_returns_continue(self): from iac_code.pipeline.engine.interrupt import InterruptController diff --git a/tests/pipeline/engine/test_pipeline_runner_interrupt.py b/tests/pipeline/engine/test_pipeline_runner_interrupt.py index e70a9aae..c019b358 100644 --- a/tests/pipeline/engine/test_pipeline_runner_interrupt.py +++ b/tests/pipeline/engine/test_pipeline_runner_interrupt.py @@ -5,6 +5,7 @@ import pytest +from iac_code.agent.message import ImageBlock, Message, TextBlock, ToolResultBlock from iac_code.pipeline.engine.events import PipelineEventType from iac_code.pipeline.engine.interrupt import InterruptVerdict from iac_code.pipeline.engine.pipeline_runner import PipelineRunner, RestartInfo @@ -23,6 +24,18 @@ def session_path(self, cwd, session_id): return self._path +class FakeTranscriptStorage: + def __init__(self, messages_by_id=None): + self.messages_by_id = messages_by_id or {} + + def load(self, cwd, session_id): + return list(self.messages_by_id.get(session_id, [])) + + @staticmethod + def repair_interrupted(messages): + return list(messages) + + class RecordingPipelineSession: def __init__(self): self.calls = [] @@ -432,6 +445,32 @@ async def test_supplement_verdict_passes_input_to_current_step(self, pipeline_ru judge.assert_awaited_once_with("use a smaller instance") cont.assert_called_once_with(user_input="use a smaller instance", resume_running_step=True) + @pytest.mark.asyncio + async def test_supplement_verdict_preserves_image_blocks_for_current_step(self, pipeline_runner): + verdict = InterruptVerdict(action="supplement", reason="extra context") + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + + with ( + patch.object(pipeline_runner._interrupt_controller, "judge", AsyncMock(return_value=verdict)) as judge, + patch.object(pipeline_runner, "_continue_from_current", MagicMock(return_value=_empty_stream())) as cont, + ): + async for _event in pipeline_runner.continue_from_sidecar(user_input=image_input): + pass + + judge.assert_awaited_once() + judged_input = judge.await_args.args[0] + assert judged_input.content == image_input + assert judged_input.display_text == "参考这张图" + assert judged_input.has_images is True + cont.assert_called_once_with( + user_input=image_input, + user_input_display_text="参考这张图", + resume_running_step=True, + ) + @pytest.mark.asyncio async def test_restored_parallel_continuation_judges_with_persisted_candidate_state(self, pipeline_runner): _seed_restored_parallel_judge_state(pipeline_runner) @@ -1403,6 +1442,29 @@ async def test_continue_after_interrupt_passes_context(self, pipeline_runner): assert pipeline_runner._rollback_context is None await gen.aclose() + @pytest.mark.asyncio + async def test_continue_after_interrupt_preserves_image_source_input(self, pipeline_runner): + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + verdict = InterruptVerdict( + action="hard_interrupt", + reason="changed mind", + rollback_target="a", + rollback_context="用户要求改为WordPress网站", + ) + pipeline_runner.apply_hard_interrupt(verdict, source_input=image_input) + + expected_display = "用户要求改为WordPress网站\n\n参考这张图" + expected_content = [TextBlock(text="用户要求改为WordPress网站"), *image_input] + with patch.object(pipeline_runner, "_continue_from_current", MagicMock(return_value=_empty_stream())) as cont: + gen = pipeline_runner.continue_after_interrupt() + await gen.aclose() + + assert pipeline_runner._rollback_context is None + cont.assert_called_once_with(user_input=expected_content, user_input_display_text=expected_display) + def test_schedule_candidate_restart_stores_rollback_context(self, pipeline_runner): mock_task = MagicMock() mock_task.done.return_value = False @@ -1426,6 +1488,643 @@ def test_schedule_candidate_restart_stores_rollback_context(self, pipeline_runne assert pipeline_runner._pending_candidate_restarts[0].rollback_context == "用户要求模板使用WordPress" + def test_schedule_candidate_restart_preserves_image_source_input(self, pipeline_runner): + mock_task = MagicMock() + mock_task.done.return_value = False + mock_task.cancel = MagicMock() + pipeline_runner._active_candidates[0] = { + "task": mock_task, + "current_sub_step": "template_generating", + "conclusions": {}, + "name": "基础方案", + "agent_loop": None, + } + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + + verdict = InterruptVerdict( + action="hard_interrupt", + reason="fix template", + rollback_target="template_generating", + candidate_scope="candidate:0", + rollback_context="用户要求模板使用WordPress", + ) + pipeline_runner._schedule_candidate_restart(verdict, source_input=image_input) + + info = pipeline_runner._pending_candidate_restarts[0] + assert info.rollback_context == "用户要求模板使用WordPress\n\n参考这张图" + assert info.rollback_input == [TextBlock(text="用户要求模板使用WordPress"), *image_input] + + def test_candidate_ask_user_question_keeps_tool_result_before_image_message(self, pipeline_runner): + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + tool_result = ToolResultBlock( + tool_use_id="toolu_1", + content='{"selected_id":"","selected_label":"","free_text":"see image"}', + ) + tool_result_message = Message(role="user", content=[tool_result]) + pipeline_runner._restored_ask_user_question = { + "candidate_index": 0, + "resume_messages": [tool_result_message], + "user_message": image_input, + "precompleted_tools": {"ask_user_question": {"free_text": "see image"}}, + } + + assert pipeline_runner._candidate_resume_messages_for_restored_ask_user_question(0) == [tool_result_message] + assert pipeline_runner._candidate_user_message_for_restored_ask_user_question(0) == image_input + + def test_candidate_ask_user_question_resume_state_survives_execution_snapshot(self, pipeline_runner): + _seed_restored_parallel_judge_state(pipeline_runner) + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + tool_result = ToolResultBlock( + tool_use_id="toolu_1", + content='{"selected_id":"","selected_label":"","free_text":"see image"}', + ) + tool_result_message = Message(role="user", content=[tool_result]) + precompleted_tools = {"ask_user_question": {"free_text": "see image"}} + + pipeline_runner._set_candidate_ask_user_question_resume_state( + 0, + user_message=image_input, + resume_messages=[tool_result_message], + precompleted_tools=precompleted_tools, + ) + + restored = PipelineRunner( + pipeline_dir=pipeline_runner._pipeline_dir, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=FakeSessionStorage(), + session_id="restored", + cwd=pipeline_runner._cwd, + ) + restored._execution = dict(pipeline_runner._execution) + + assert restored._candidate_user_message_for_restored_ask_user_question(0) == image_input + assert restored._candidate_resume_messages_for_restored_ask_user_question(0) == [tool_result_message] + assert restored._candidate_precompleted_tools_for_restored_ask_user_question(0) == precompleted_tools + + @pytest.mark.asyncio + async def test_restored_candidate_ask_user_question_image_resume_reaches_sub_pipeline(self, tmp_path): + from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "sub_a.md").write_text("sub A", encoding="utf-8") + (tmp_path / "pipeline.yaml").write_text( + dedent("""\ + name: test + context_dependencies: + architecture: [] + candidates_done: [architecture] + max_rollbacks: 3 + sub_pipelines: + per_candidate: + iterate_over: architecture.candidates + context_fields_from_parent: [] + max_rollbacks: 3 + steps: + - id: sub_a + conclusion_field: sub_a_out + forward: null + prompt: prompts/sub_a.md + description: Sub A + steps: + - id: parallel + conclusion_field: candidates_done + type: parallel_sub_pipeline + sub_pipeline: per_candidate + forward: null + description: Parallel + """), + encoding="utf-8", + ) + runner = PipelineRunner( + pipeline_dir=tmp_path, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=FakeSessionStorage(), + session_id="test", + cwd=str(tmp_path), + ) + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + tool_result = ToolResultBlock( + tool_use_id="toolu_1", + content='{"selected_id":"","selected_label":"","free_text":"see image"}', + ) + tool_result_message = Message(role="user", content=[tool_result]) + precompleted_tools = {"ask_user_question": {"free_text": "see image"}} + candidates = [{"name": "方案1"}] + runner.context.set_conclusion("architecture", {"candidates": candidates}) + runner._execution = { + "kind": "parallel_sub_pipeline", + "step_id": "parallel", + "sub_pipeline_name": "per_candidate", + "active_attempt_id": "attempt_parent", + "transcript_id": "transcript_parent", + "candidates": { + "0": { + "status": "running", + "candidate": candidates[0], + "current_sub_step": "sub_a", + "state_machine": {"current_index": 0, "completed": [], "rollback_count": 0}, + "context": {"fields": {}}, + "pending_ask_user_question_resume": { + "user_message": [ + {"type": "text", "text": "参考这张图"}, + {"type": "image", "media_type": "image/png", "data": "aW1hZ2U="}, + ], + "resume_messages": [tool_result_message.to_dict()], + "precompleted_tools": precompleted_tools, + }, + }, + }, + } + runner._attempts["items"]["attempt_parent"] = { + "attempt_id": "attempt_parent", + "scope": "parent", + "step_id": "parallel", + "status": "running", + "transcript_id": "transcript_parent", + } + captured: dict[str, object] = {} + + from iac_code.pipeline.engine import sub_pipeline_executor as spe_module + + original_execute_streaming = spe_module.SubPipelineExecutor.execute_streaming + + async def spy_execute_streaming(self, *args, **kwargs): + captured.update(kwargs) + yield PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=0.0, + data={ + "sub_pipeline_id": "x", + "candidate_index": kwargs.get("candidate_index", 0), + "candidate_name": "方案", + "total_steps": 1, + "sub_pipeline_name": "per_candidate", + }, + ) + yield PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_COMPLETED, + step_id=None, + timestamp=0.0, + data={ + "sub_pipeline_id": "x", + "candidate_index": kwargs.get("candidate_index", 0), + "failed": False, + "conclusions": {}, + }, + ) + + spe_module.SubPipelineExecutor.execute_streaming = spy_execute_streaming + try: + async for _event in runner._continue_from_current(user_input=None): + pass + finally: + spe_module.SubPipelineExecutor.execute_streaming = original_execute_streaming + + assert captured["user_message"] == image_input + assert captured["resume_messages"] == [tool_result_message] + assert captured["precompleted_tools"] == precompleted_tools + + @pytest.mark.asyncio + async def test_parent_ask_user_question_image_resume_state_survives_sidecar_restore(self, pipeline_runner): + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + tool_result = ToolResultBlock( + tool_use_id="toolu_1", + content='{"selected_id":"","selected_label":"","free_text":"see image"}', + ) + tool_result_message = Message(role="user", content=[tool_result]) + precompleted_tools = {"ask_user_question": {"free_text": "see image"}} + + pipeline_runner._set_current_step_user_input(image_input, display_text="参考这张图") + pipeline_runner._set_current_step_resume_state( + resume_messages=[tool_result_message], + precompleted_tools=precompleted_tools, + ) + snapshot = pipeline_runner._state_machine_snapshot_for_sidecar() + + restored = PipelineRunner( + pipeline_dir=pipeline_runner._pipeline_dir, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=FakeSessionStorage(), + session_id="restored", + cwd=pipeline_runner._cwd, + ) + restored.state_machine = type(pipeline_runner.state_machine).from_snapshot( + snapshot, + restored._loaded.steps, + max_rollbacks=restored._loaded.max_rollbacks, + ) + restored._restore_current_step_user_input_from_snapshot(snapshot) + + captured: dict[str, object] = {} + + async def capture_execute(*args, **kwargs): + captured.update(kwargs) + if False: + yield None + + restored._step_executor.execute = capture_execute + + async for _event in restored._continue_from_current(user_input=None): + pass + + assert captured["user_message"] == image_input + assert captured["resume_messages"] == [tool_result_message] + assert captured["precompleted_tools"] == precompleted_tools + + @pytest.mark.asyncio + async def test_parent_ask_user_question_restore_prefers_transcript_with_answer(self, pipeline_runner): + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + tool_result = ToolResultBlock( + tool_use_id="toolu_1", + content='{"selected_id":"","selected_label":"","free_text":"see image"}', + ) + tool_result_message = Message(role="user", content=[tool_result]) + post_answer_message = Message(role="assistant", content=[TextBlock(text="我已经读取图片")]) + precompleted_tools = {"ask_user_question": {"free_text": "see image"}} + + pipeline_runner._set_current_step_user_input(image_input, display_text="参考这张图") + pipeline_runner._set_current_step_resume_state( + resume_messages=[tool_result_message], + precompleted_tools=precompleted_tools, + ) + snapshot = pipeline_runner._state_machine_snapshot_for_sidecar() + + restored = PipelineRunner( + pipeline_dir=pipeline_runner._pipeline_dir, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=FakeSessionStorage(), + session_id="restored", + cwd=pipeline_runner._cwd, + ) + restored.state_machine = type(pipeline_runner.state_machine).from_snapshot( + snapshot, + restored._loaded.steps, + max_rollbacks=restored._loaded.max_rollbacks, + ) + restored._restore_current_step_user_input_from_snapshot(snapshot) + restored._attempts["items"]["attempt_parent"] = { + "attempt_id": "attempt_parent", + "scope": "parent", + "step_id": "a", + "status": "running", + "transcript_id": "transcript_parent", + } + restored._execution = { + "kind": "step", + "step_id": "a", + "active_attempt_id": "attempt_parent", + "transcript_id": "transcript_parent", + } + restored._transcript_storage = FakeTranscriptStorage( + {"transcript_parent": [tool_result_message, post_answer_message]} + ) + captured: dict[str, object] = {} + + async def capture_execute(*args, **kwargs): + captured.update(kwargs) + if False: + yield None + + restored._step_executor.execute = capture_execute + + async for _event in restored._continue_from_current(user_input=None): + pass + + assert captured["resume_messages"] == [tool_result_message, post_answer_message] + assert captured["precompleted_tools"] == precompleted_tools + + @pytest.mark.asyncio + async def test_parent_ask_user_question_restore_does_not_replay_transcript_image_user_message( + self, + pipeline_runner, + ): + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + tool_result = ToolResultBlock( + tool_use_id="toolu_1", + content='{"selected_id":"","selected_label":"","free_text":"see image"}', + ) + tool_result_message = Message(role="user", content=[tool_result]) + image_message = Message(role="user", content=image_input) + precompleted_tools = {"ask_user_question": {"free_text": "see image"}} + + pipeline_runner._set_current_step_user_input(image_input, display_text="参考这张图") + pipeline_runner._set_current_step_resume_state( + resume_messages=[tool_result_message], + precompleted_tools=precompleted_tools, + ) + snapshot = pipeline_runner._state_machine_snapshot_for_sidecar() + + restored = PipelineRunner( + pipeline_dir=pipeline_runner._pipeline_dir, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=FakeSessionStorage(), + session_id="restored", + cwd=pipeline_runner._cwd, + ) + restored.state_machine = type(pipeline_runner.state_machine).from_snapshot( + snapshot, + restored._loaded.steps, + max_rollbacks=restored._loaded.max_rollbacks, + ) + restored._restore_current_step_user_input_from_snapshot(snapshot) + restored._attempts["items"]["attempt_parent"] = { + "attempt_id": "attempt_parent", + "scope": "parent", + "step_id": "a", + "status": "running", + "transcript_id": "transcript_parent", + } + restored._execution = { + "kind": "step", + "step_id": "a", + "active_attempt_id": "attempt_parent", + "transcript_id": "transcript_parent", + } + restored._transcript_storage = FakeTranscriptStorage( + {"transcript_parent": [tool_result_message, image_message]} + ) + captured: dict[str, object] = {} + + async def capture_execute(*args, **kwargs): + captured.update(kwargs) + if False: + yield None + + restored._step_executor.execute = capture_execute + + async for _event in restored._continue_from_current(user_input=None): + pass + + assert captured["user_message"] is None + assert captured["resume_messages"] == [tool_result_message, image_message] + assert captured["precompleted_tools"] == precompleted_tools + + @pytest.mark.asyncio + async def test_sub_pipeline_ask_user_question_restore_does_not_duplicate_transcript_answer(self, tmp_path): + from iac_code.pipeline.engine.context import PipelineContext + from iac_code.pipeline.engine.step_spec import LoadedPipeline, StepSpec, SubPipelineSpec + from iac_code.pipeline.engine.sub_pipeline_executor import SubPipelineExecutor + from iac_code.pipeline.engine.types import StepResult, StepStatus + + step = StepSpec( + step_id="sub_a", + conclusion_field="sub_a_out", + forward=None, + prompt_file="prompts/sub_a.md", + description="Sub A", + ) + sub_spec = SubPipelineSpec( + name="per_candidate", + steps=[step], + max_rollbacks=3, + iterate_over="architecture.candidates", + ) + loaded = LoadedPipeline( + name="test", + steps=[], + context_dependencies={}, + max_rollbacks=3, + skills={}, + sub_pipelines={"per_candidate": sub_spec}, + ) + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + pipeline=loaded, + pipeline_dir=tmp_path, + session_storage=FakeSessionStorage(), + cwd=str(tmp_path), + ) + tool_result = ToolResultBlock( + tool_use_id="toolu_1", + content='{"selected_id":"","selected_label":"","free_text":"see image"}', + ) + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + tool_result_message = Message(role="user", content=[tool_result]) + post_answer_message = Message(role="assistant", content=[TextBlock(text="我已经读取图片")]) + captured: dict[str, object] = {} + + class CapturingStepExecutor: + async def execute(self, step, context, session_id, **kwargs): + captured.update(kwargs) + yield StepResult( + step_id=step.step_id, + status=StepStatus.COMPLETED, + conclusion={"ok": True}, + ) + + executor._make_step_executor = lambda: CapturingStepExecutor() + + def allocate_sub_step_attempt(request): + return { + "attempt_id": "attempt_sub", + "transcript_id": "transcript_sub", + "resume_messages": [tool_result_message, post_answer_message], + } + + async for _event in executor.execute_streaming( + sub_spec=sub_spec, + candidate={"name": "方案1"}, + candidate_index=0, + parent_context=PipelineContext({}), + session_id="session", + user_message=image_input, + resume_messages=[tool_result_message], + parent_step_id="parallel", + resume_state={ + "sub_pipeline_id": "sub", + "state_machine": { + "current_index": 0, + "rollback_count": 0, + "interrupt_rollback_count": 0, + "step_statuses": {}, + }, + "context": {"fields": {}}, + "active_attempt_id": "attempt_sub", + "transcript_id": "transcript_sub", + "current_sub_step": "sub_a", + }, + sub_step_attempt_allocator=allocate_sub_step_attempt, + precompleted_tools={"ask_user_question": {"free_text": "see image"}}, + ): + pass + + assert captured["resume_messages"] == [tool_result_message, post_answer_message] + + @pytest.mark.asyncio + async def test_sub_pipeline_ask_user_question_restore_does_not_replay_transcript_image_user_message( + self, + tmp_path, + ): + from iac_code.pipeline.engine.context import PipelineContext + from iac_code.pipeline.engine.step_spec import LoadedPipeline, StepSpec, SubPipelineSpec + from iac_code.pipeline.engine.sub_pipeline_executor import SubPipelineExecutor + from iac_code.pipeline.engine.types import StepResult, StepStatus + + step = StepSpec( + step_id="sub_a", + conclusion_field="sub_a_out", + forward=None, + prompt_file="prompts/sub_a.md", + description="Sub A", + ) + sub_spec = SubPipelineSpec( + name="per_candidate", + steps=[step], + max_rollbacks=3, + iterate_over="architecture.candidates", + ) + loaded = LoadedPipeline( + name="test", + steps=[], + context_dependencies={}, + max_rollbacks=3, + skills={}, + sub_pipelines={"per_candidate": sub_spec}, + ) + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + pipeline=loaded, + pipeline_dir=tmp_path, + session_storage=FakeSessionStorage(), + cwd=str(tmp_path), + ) + tool_result = ToolResultBlock( + tool_use_id="toolu_1", + content='{"selected_id":"","selected_label":"","free_text":"see image"}', + ) + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + tool_result_message = Message(role="user", content=[tool_result]) + image_message = Message(role="user", content=image_input) + captured: dict[str, object] = {} + + class CapturingStepExecutor: + async def execute(self, step, context, session_id, **kwargs): + captured.update(kwargs) + yield StepResult( + step_id=step.step_id, + status=StepStatus.COMPLETED, + conclusion={"ok": True}, + ) + + executor._make_step_executor = lambda: CapturingStepExecutor() + + def allocate_sub_step_attempt(request): + return { + "attempt_id": "attempt_sub", + "transcript_id": "transcript_sub", + "resume_messages": [tool_result_message, image_message], + } + + async for _event in executor.execute_streaming( + sub_spec=sub_spec, + candidate={"name": "方案1"}, + candidate_index=0, + parent_context=PipelineContext({}), + session_id="session", + user_message=image_input, + resume_messages=[tool_result_message], + parent_step_id="parallel", + resume_state={ + "sub_pipeline_id": "sub", + "state_machine": { + "current_index": 0, + "rollback_count": 0, + "interrupt_rollback_count": 0, + "step_statuses": {}, + }, + "context": {"fields": {}}, + "active_attempt_id": "attempt_sub", + "transcript_id": "transcript_sub", + "current_sub_step": "sub_a", + }, + sub_step_attempt_allocator=allocate_sub_step_attempt, + precompleted_tools={"ask_user_question": {"free_text": "see image"}}, + ): + pass + + assert captured["user_message"] is None + assert captured["resume_messages"] == [tool_result_message, image_message] + + def test_inject_pending_question_supplement_preserves_image_blocks(self, pipeline_runner): + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + injected_messages = [] + + class AgentLoop: + def try_inject_user_message(self, message): + injected_messages.append(message) + return True + + agent_loop = AgentLoop() + pipeline_runner._step_executor._current_agent_loop = agent_loop + + injected = pipeline_runner.inject_pending_question_supplement( + image_input, + envelope={"scope": "pipeline", "inputId": "ask-toolu_1"}, + ) + + assert injected is True + assert injected_messages == [image_input] + + def test_inject_pending_question_supplement_treats_none_return_as_success(self, pipeline_runner): + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + injected_messages = [] + + class AgentLoop: + def try_inject_user_message(self, message): + injected_messages.append(message) + + agent_loop = AgentLoop() + pipeline_runner._step_executor._current_agent_loop = agent_loop + + injected = pipeline_runner.inject_pending_question_supplement( + image_input, + envelope={"scope": "pipeline", "inputId": "ask-toolu_1"}, + ) + + assert injected is True + assert injected_messages == [image_input] + class TestParallelSubPipelineUserMessagePropagation: """Regression: `user_input` from `_continue_from_current` must reach the @@ -1890,6 +2589,128 @@ async def spy_execute_streaming(self, *args, **kwargs): assert captured[1]["user_message"] == "restored restart feedback" assert isinstance(captured[1]["resume_state"], dict) + @pytest.mark.asyncio + async def test_restored_candidate_restart_preserves_image_rollback_input(self, tmp_path): + from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "sub_a.md").write_text("sub A", encoding="utf-8") + (tmp_path / "pipeline.yaml").write_text( + dedent("""\ + name: test + context_dependencies: + architecture: [] + candidates_done: [architecture] + max_rollbacks: 3 + sub_pipelines: + per_candidate: + iterate_over: architecture.candidates + context_fields_from_parent: [] + max_rollbacks: 3 + steps: + - id: sub_a + conclusion_field: sub_a_out + forward: null + prompt: prompts/sub_a.md + description: Sub A + steps: + - id: parallel + conclusion_field: candidates_done + type: parallel_sub_pipeline + sub_pipeline: per_candidate + forward: null + description: Parallel + """), + encoding="utf-8", + ) + runner = PipelineRunner( + pipeline_dir=tmp_path, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=FakeSessionStorage(), + session_id="test", + cwd=str(tmp_path), + ) + image_input = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + candidates = [{"name": "方案1"}] + runner.context.set_conclusion("architecture", {"candidates": candidates}) + runner._execution = { + "kind": "parallel_sub_pipeline", + "step_id": "parallel", + "sub_pipeline_name": "per_candidate", + "active_attempt_id": "attempt_parent", + "transcript_id": "transcript_parent", + "candidates": { + "0": { + "status": "running", + "candidate": candidates[0], + "current_sub_step": "sub_a", + "pending_restart": { + "start_from_step": "sub_a", + "preserved_conclusions": {}, + "rollback_context": "用户要求改架构\n\n参考这张图", + "rollback_input": [ + {"type": "text", "text": "用户要求改架构"}, + {"type": "text", "text": "参考这张图"}, + {"type": "image", "media_type": "image/png", "data": "aW1hZ2U="}, + ], + }, + }, + }, + } + runner._attempts["items"]["attempt_parent"] = { + "attempt_id": "attempt_parent", + "scope": "parent", + "step_id": "parallel", + "status": "running", + "transcript_id": "transcript_parent", + } + captured: dict[int, dict[str, object]] = {} + + from iac_code.pipeline.engine import sub_pipeline_executor as spe_module + + original_execute_streaming = spe_module.SubPipelineExecutor.execute_streaming + + async def spy_execute_streaming(self, *args, **kwargs): + captured[kwargs["candidate_index"]] = {"user_message": kwargs.get("user_message")} + yield PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=0.0, + data={ + "sub_pipeline_id": "x", + "candidate_index": kwargs.get("candidate_index", 0), + "candidate_name": "方案", + "total_steps": 1, + "sub_pipeline_name": "per_candidate", + }, + ) + yield PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_COMPLETED, + step_id=None, + timestamp=0.0, + data={ + "sub_pipeline_id": "x", + "candidate_index": kwargs.get("candidate_index", 0), + "failed": False, + "conclusions": {}, + }, + ) + + spe_module.SubPipelineExecutor.execute_streaming = spy_execute_streaming + try: + async for _ev in runner._continue_from_current(user_input=None): + if captured: + break + finally: + spe_module.SubPipelineExecutor.execute_streaming = original_execute_streaming + + assert captured[0]["user_message"] == [TextBlock(text="用户要求改架构"), *image_input] + class TestSupplementTargetUnifiedFormat: """P-I5: _inject_supplement should accept both 'candidate:N' (new unified) diff --git a/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py b/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py index 76af42e2..0d80c87c 100644 --- a/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py +++ b/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py @@ -8,7 +8,7 @@ import pytest import yaml -from iac_code.agent.message import Message, ToolResultBlock +from iac_code.agent.message import ImageBlock, Message, TextBlock, ToolResultBlock from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.types import StepResult, StepStatus @@ -469,6 +469,40 @@ async def fake_execute(step, context, session_id, user_message=None, **_kwargs): assert seen_user_messages == ["选择一个已有vpc,创建一个vswitch"] +@pytest.mark.asyncio +async def test_continue_from_sidecar_reuses_persisted_current_step_image_input(tmp_path): + from iac_code.pipeline.engine.user_input import PipelineUserInput + + image_input = PipelineUserInput( + content=[ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ], + display_text="参考这张图", + has_images=True, + ) + runner = _build_two_step_runner(tmp_path) + runner._set_current_step_user_input(image_input) + await runner._save_running("s1", reason="step started") + + runner2 = _build_two_step_runner(tmp_path, resume_from_sidecar=True) + seen_user_messages = [] + + async def fake_execute(step, context, session_id, user_message=None, **_kwargs): + seen_user_messages.append(user_message) + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner2._step_executor.execute = fake_execute + + async for _event in runner2.continue_from_sidecar(): + if seen_user_messages: + break + + assert seen_user_messages == [image_input.content] + + @pytest.mark.asyncio async def test_hard_interrupt_rollback_context_survives_sidecar_restore(tmp_path): from iac_code.pipeline.engine.interrupt import InterruptVerdict diff --git a/tests/pipeline/engine/test_resume_recovery.py b/tests/pipeline/engine/test_resume_recovery.py new file mode 100644 index 00000000..6fef87b0 --- /dev/null +++ b/tests/pipeline/engine/test_resume_recovery.py @@ -0,0 +1,35 @@ +from iac_code.agent.message import ImageBlock, Message, TextBlock, ToolResultBlock +from iac_code.pipeline.engine.resume_recovery import reconcile_resume_messages, user_message_already_in_resume + + +def test_reconcile_resume_messages_filters_duplicate_tool_result_blocks_only(): + existing = Message( + role="user", + content=[ToolResultBlock(tool_use_id="toolu_existing", content="done")], + ) + sidecar = Message( + role="user", + content=[ + ToolResultBlock(tool_use_id="toolu_existing", content="done"), + ToolResultBlock(tool_use_id="toolu_new", content="new"), + ], + ) + + merged = reconcile_resume_messages([existing], [sidecar]) + + assert merged is not None + assert len(merged) == 2 + assert merged[0] == existing + assert merged[1] == Message( + role="user", + content=[ToolResultBlock(tool_use_id="toolu_new", content="new")], + ) + + +def test_user_message_already_in_resume_matches_image_message(): + image_message = [ + TextBlock(text="参考这张图"), + ImageBlock(media_type="image/png", data="aW1hZ2U="), + ] + + assert user_message_already_in_resume(image_message, [Message(role="user", content=image_message)]) is True diff --git a/tests/pipeline/engine/test_sub_pipeline_executor.py b/tests/pipeline/engine/test_sub_pipeline_executor.py index 5d24a481..19a6b90f 100644 --- a/tests/pipeline/engine/test_sub_pipeline_executor.py +++ b/tests/pipeline/engine/test_sub_pipeline_executor.py @@ -5,6 +5,7 @@ import pytest +from iac_code.agent.message import ImageBlock, Message, ToolResultBlock, ToolUseBlock from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.state_machine import StateMachine @@ -188,6 +189,85 @@ async def execute( } ] + @pytest.mark.asyncio + async def test_execute_streaming_appends_explicit_resume_messages_to_repaired_transcript( + self, tmp_path, monkeypatch + ): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "template.md").write_text("Generate template", encoding="utf-8") + sub_spec = SubPipelineSpec( + name="evaluate_candidate", + steps=[ + StepSpec( + step_id="template_generating", + conclusion_field="template", + forward=None, + prompt_file="prompts/template.md", + skill="iac_aliyun", + context_fields=["candidate", "intent"], + ) + ], + max_rollbacks=2, + iterate_over="architecture.candidates", + context_fields_from_parent=["intent"], + ) + repaired = [ + Message( + role="assistant", + content=[ToolUseBlock(id="toolu_1", name="ask_user_question", input={"question": "q"})], + ) + ] + tool_result = Message( + role="user", + content=[ToolResultBlock(tool_use_id="toolu_1", content='{"free_text":"see image"}', is_error=False)], + ) + image_message = [ImageBlock(media_type="image/png", data="aGVsbG8=")] + captured = {} + + class FakeStepExecutor: + current_agent_loop = None + + async def execute(self, step, context, session_id, user_message=None, **kwargs): + captured["resume_messages"] = kwargs["resume_messages"] + captured["user_message"] = user_message + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion={"body": "ok"}) + + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=LoadedPipeline( + name="test", + steps=[], + context_dependencies={"intent": []}, + max_rollbacks=3, + skills={"iac_aliyun": "# IaC Skill", "iac-aliyun-cost": "# Cost Skill"}, + ), + pipeline_dir=tmp_path, + ) + monkeypatch.setattr(executor, "_make_step_executor", lambda: FakeStepExecutor()) + parent_ctx = PipelineContext({"intent": []}) + parent_ctx.set_conclusion("intent", {"type": "test"}) + + async for _event in executor.execute_streaming( + sub_spec=sub_spec, + candidate={"name": "Plan A"}, + candidate_index=0, + parent_context=parent_ctx, + session_id="test_session", + user_message=image_message, + resume_messages=[tool_result], + resume_state={ + "current_sub_step": "template_generating", + "active_attempt_id": "att_1", + "transcript_id": "transcript_1", + "resume_messages": repaired, + }, + ): + pass + + assert captured["resume_messages"] == [*repaired, tool_result] + assert captured["user_message"] == image_message + @pytest.mark.asyncio async def test_completed_sub_step_resume_state_starts_at_next_sub_step(self, tmp_path, monkeypatch): """A crash after persisting sub-step completion must resume at the next sub-step.""" diff --git a/tests/pipeline/engine/test_transcript_storage.py b/tests/pipeline/engine/test_transcript_storage.py index 313612b4..23b0575e 100644 --- a/tests/pipeline/engine/test_transcript_storage.py +++ b/tests/pipeline/engine/test_transcript_storage.py @@ -4,7 +4,7 @@ from pathlib import Path from iac_code import __version__ -from iac_code.agent.message import Message, TextBlock, ToolUseBlock +from iac_code.agent.message import ImageBlock, Message, TextBlock, ToolUseBlock from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage @@ -26,6 +26,21 @@ def test_append_and_load_roundtrip(tmp_path: Path): assert messages[1].get_text() == "hi" +def test_pipeline_transcript_round_trips_image_blocks(tmp_path: Path): + storage = PipelineTranscriptStorage(tmp_path / "pipeline") + messages = [ + Message( + role="user", + content=[TextBlock(text="diagram"), ImageBlock(media_type="image/png", data="aGVsbG8=")], + ) + ] + + storage.save("/repo", "transcript_att_0001", messages) + loaded = storage.load("/repo", "transcript_att_0001") + + assert loaded == messages + + def test_transcript_lives_inside_sidecar(tmp_path: Path): storage = PipelineTranscriptStorage(tmp_path / "pipeline") diff --git a/tests/pipeline/engine/test_user_input.py b/tests/pipeline/engine/test_user_input.py new file mode 100644 index 00000000..538b5d83 --- /dev/null +++ b/tests/pipeline/engine/test_user_input.py @@ -0,0 +1,55 @@ +from iac_code.agent.message import ImageBlock, TextBlock, ToolResultBlock +from iac_code.pipeline.engine.user_input import ( + PipelineUserInput, + content_display_text, + content_has_images, + normalize_pipeline_user_input, +) + + +def test_normalize_string_input() -> None: + value = normalize_pipeline_user_input("create an ecs") + + assert value == PipelineUserInput( + content="create an ecs", + display_text="create an ecs", + has_images=False, + ) + assert value.is_empty is False + + +def test_normalize_image_only_input_is_not_empty() -> None: + image = ImageBlock(media_type="image/png", data="aGVsbG8=") + + value = normalize_pipeline_user_input([image]) + + assert value.content == [image] + assert value.display_text == "[Image input]" + assert value.has_images is True + assert value.is_empty is False + + +def test_content_display_text_extracts_text_and_tool_result_without_image_bytes() -> None: + blocks = [ + TextBlock(text="text part"), + ImageBlock(media_type="image/png", data="aGVsbG8="), + ToolResultBlock(tool_use_id="toolu_1", content='{"answer":"ok"}'), + ] + + assert content_has_images(blocks) is True + assert content_display_text(blocks) == 'text part\n{"answer":"ok"}' + + +def test_with_prepended_text_preserves_original_image_block() -> None: + image = ImageBlock(media_type="image/png", data="aGVsbG8=") + value = normalize_pipeline_user_input([TextBlock(text="original"), image]) + + updated = value.with_prepended_text("rollback context") + + assert updated.display_text == "rollback context\n\noriginal" + assert updated.has_images is True + assert updated.content == [ + TextBlock(text="rollback context"), + TextBlock(text="original"), + image, + ] diff --git a/tests/providers/test_openai_image_blocks.py b/tests/providers/test_openai_image_blocks.py index 314eb7c2..9fc00702 100644 --- a/tests/providers/test_openai_image_blocks.py +++ b/tests/providers/test_openai_image_blocks.py @@ -26,6 +26,32 @@ def test_user_image_converts_to_image_url(): ] +def test_judge_style_user_content_blocks_convert_to_image_url(): + p = OpenAIProvider(model="gpt-5.4", api_key="x") + msg = Message( + role="user", + content=[ + ContentBlock(type="text", text="judge routing prompt"), + ContentBlock(type="image", media_type="image/png", data="aGVsbG8="), + ], + ) + + api = p._convert_messages([msg]) + + assert api == [ + { + "role": "user", + "content": [ + {"type": "text", "text": "judge routing prompt"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,aGVsbG8="}, + }, + ], + } + ] + + def test_text_only_user_message_stays_string(): p = OpenAIProvider(model="gpt-5.4", api_key="x") msg = Message(role="user", content="plain") diff --git a/tests/repl_e2e/test_run_pipeline_scenarios.py b/tests/repl_e2e/test_run_pipeline_scenarios.py index bf7fbafd..6a07d36d 100644 --- a/tests/repl_e2e/test_run_pipeline_scenarios.py +++ b/tests/repl_e2e/test_run_pipeline_scenarios.py @@ -118,12 +118,25 @@ def expect_any(self, patterns, *, description, timeout): def expect_optional(self, patterns, *, description, timeout): actions.append(("expect_optional", description)) + if description == "second ask question after image answer": + return False return True def send(self, text, *, label="send"): actions.append((label, text)) self.events.append({"type": label, "text": text, "transcript_offset": 0}) + def paste_image_fixture(self, image_key: str): + actions.append(("paste-image-fixture", image_key)) + self.events.append( + { + "type": "paste-image-fixture", + "image_key": image_key, + "path": f"/repo/scripts/a2a/e2e/fixtures/text-images/{image_key}.png", + "transcript_offset": 0, + } + ) + def terminate(self, *, force=False): actions.append(("terminate", str(force))) self.events.append({"type": "terminate", "force": force}) @@ -304,6 +317,11 @@ def test_all_regression_scenarios_are_parseable() -> None: "scenario1", "ask-waiting", "ask-waiting-resume", + "image-initial", + "image-ask-waiting-resume", + "image-selection-waiting-resume", + "image-normal-handoff", + "image-interrupt", "selection-waiting-resume", "selection-invalid-then-valid", "evaluate-resume", @@ -321,6 +339,23 @@ def test_all_regression_scenarios_are_parseable() -> None: assert runner._selected_scenarios(args) == expected +def test_repl_image_fixture_paths_reuse_static_pngs() -> None: + runner = _load_runner() + + for image_key in [ + "initial", + "ask-first-answer", + "ask-second-answer", + "selection", + "normal-followup", + "rollback-interrupt", + ]: + path = runner._text_image_fixture_path(image_key) + assert path.is_file() + assert path.suffix == ".png" + assert path.parent.name == "text-images" + + def test_run_dir_requires_single_scenario() -> None: runner = _load_runner() @@ -1833,6 +1868,183 @@ def terminate(self, *, force=False): assert ("sendline", "/exit") in actions +def test_image_initial_pastes_static_prompt_image(monkeypatch, tmp_path: Path) -> None: + runner = _load_runner() + args = runner.parse_args(["--allow-real-cloud", "--run-dir", str(tmp_path)]) + actions: list[tuple[str, str]] = [] + transcript = "● Confirm and select (4/5)\n✔ Pipeline completed\n交换机 ID vsw-bp1234567890\n" + _install_flow_fake_pty(monkeypatch, runner, transcript, actions, scenario="image-initial") + + assert runner.run_image_initial(args, "image-initial") == 0 + + ordered_actions = [ + (kind, value) + for kind, value in actions + if kind in {"expect", "sendline", "paste-image-fixture", "select-default-candidate"} + ] + assert ordered_actions == [ + ("expect", "initial prompt"), + ("expect", "prompt input ready"), + ("paste-image-fixture", "initial"), + ("sendline", runner._stack_name_constraint(tmp_path, "image-initial")), + ("expect", "pipeline started"), + ("expect", "candidate selection visible"), + ("select-default-candidate", f"{args.selection_prompt}\r"), + ("expect", "pipeline completed after image initial"), + ("sendline", "/exit"), + ] + assert ("sendline", args.initial_prompt) not in actions + + +def test_image_ask_waiting_resume_pastes_static_answer_image(monkeypatch, tmp_path: Path) -> None: + runner = _load_runner() + args = runner.parse_args(["--allow-real-cloud", "--run-dir", str(tmp_path)]) + actions: list[tuple[str, str]] = [] + transcript = ( + "● Ask user question\n" + "● Ask user question\n" + "● Confirm and select (4/5)\n" + "✔ Pipeline completed\n" + "交换机 ID vsw-bp1234567890\n" + ) + _install_flow_fake_pty(monkeypatch, runner, transcript, actions, scenario="image-ask-waiting-resume") + + assert runner.run_image_ask_waiting_resume(args, "image-ask-waiting-resume") == 0 + + ordered_actions = [ + (kind, value) + for kind, value in actions + if kind in {"expect", "spawn", "terminate", "sendline", "paste-image-fixture", "select-default-candidate"} + ] + assert ordered_actions == [ + ("spawn", ""), + ("expect", "initial prompt"), + ("expect", "prompt input ready"), + ("sendline", args.ask_prompt), + ("expect", "ask question visible before kill"), + ("terminate", "True"), + ("spawn", "--continue"), + ("expect", "ask question replayed"), + ("expect", "ask image answer input ready after resume"), + ("paste-image-fixture", "ask-first-answer"), + ("sendline", runner._stack_name_constraint(tmp_path, "image-ask-waiting-resume")), + ("expect", "pipeline continued after ask image resume"), + ("select-default-candidate", f"{args.selection_prompt}\r"), + ("expect", "pipeline completed after ask image resume"), + ("sendline", "/exit"), + ("terminate", "False"), + ] + assert ("sendline", args.ask_answer) not in actions + + +def test_image_selection_waiting_resume_starts_with_image_and_recovers_selection(monkeypatch, tmp_path: Path) -> None: + runner = _load_runner() + args = runner.parse_args(["--allow-real-cloud", "--run-dir", str(tmp_path)]) + actions: list[tuple[str, str]] = [] + transcript = ( + "● Confirm and select (4/5)\n● Confirm and select (4/5)\n✔ Pipeline completed\n交换机 ID vsw-bp1234567890\n" + ) + _install_flow_fake_pty(monkeypatch, runner, transcript, actions, scenario="image-selection-waiting-resume") + + assert runner.run_image_selection_waiting_resume(args, "image-selection-waiting-resume") == 0 + + ordered_actions = [ + (kind, value) + for kind, value in actions + if kind in {"expect", "spawn", "terminate", "sendline", "paste-image-fixture", "select-default-candidate"} + ] + assert ordered_actions == [ + ("spawn", ""), + ("expect", "initial prompt"), + ("expect", "prompt input ready"), + ("paste-image-fixture", "initial"), + ("sendline", runner._stack_name_constraint(tmp_path, "image-selection-waiting-resume")), + ("expect", "candidate selection visible before image resume kill"), + ("terminate", "True"), + ("spawn", "--continue"), + ("expect", "candidate selection replayed after image resume"), + ("select-default-candidate", f"{args.selection_prompt}\r"), + ("expect", "pipeline completed after image selection resume"), + ("sendline", "/exit"), + ("terminate", "False"), + ] + + +def test_image_normal_handoff_pastes_static_followup_image(monkeypatch, tmp_path: Path) -> None: + runner = _load_runner() + args = runner.parse_args(["--allow-real-cloud", "--run-dir", str(tmp_path)]) + actions: list[tuple[str, str]] = [] + transcript = ( + "● Confirm and select (4/5)\n" + "✔ Pipeline completed\n" + "Pipeline completed. Normal chat is now active.\n" + "[Image #1]\n" + "刚才创建了一个 VSwitch 交换机。\n" + "交换机 ID vsw-bp1234567890\n" + ) + _install_flow_fake_pty(monkeypatch, runner, transcript, actions, scenario="image-normal-handoff") + stack_owned_initial = runner._stack_creating_prompt(args.initial_prompt, tmp_path, "image-normal-handoff") + + assert runner.run_image_normal_handoff(args, "image-normal-handoff") == 0 + + ordered_actions = [ + (kind, value) + for kind, value in actions + if kind in {"expect", "sendline", "paste-image-fixture", "submit-image", "select-default-candidate"} + ] + assert ordered_actions == [ + ("expect", "initial prompt"), + ("expect", "prompt input ready"), + ("sendline", stack_owned_initial), + ("expect", "pipeline started"), + ("expect", "candidate selection visible"), + ("select-default-candidate", f"{args.selection_prompt}\r"), + ("expect", "pipeline fully completed"), + ("expect", "normal prompt input ready"), + ("paste-image-fixture", "normal-followup"), + ("submit-image", "\r"), + ("expect", "normal image follow-up answered created VSwitch"), + ("sendline", "/exit"), + ] + assert ("sendline", args.normal_followup_prompt) not in actions + + +def test_image_interrupt_pastes_static_rollback_image(monkeypatch, tmp_path: Path) -> None: + runner = _load_runner() + args = runner.parse_args(["--allow-real-cloud", "--run-dir", str(tmp_path)]) + actions: list[tuple[str, str]] = [] + transcript = ( + "● Evaluate candidates (3/5)\n" + "[Image #1]\n" + "● Intent parsing (1/5)\n" + "目标资源为 ALIYUN::ECS::SecurityGroup 安全组。\n" + ) + _install_flow_fake_pty(monkeypatch, runner, transcript, actions, scenario="image-interrupt") + + assert runner.run_image_interrupt(args, "image-interrupt") == 0 + + ordered_actions = [ + (kind, value) + for kind, value in actions + if kind in {"expect", "send-esc", "sendline", "paste-image-fixture", "submit-image"} + ] + assert ordered_actions == [ + ("expect", "initial prompt"), + ("expect", "prompt input ready"), + ("sendline", args.initial_prompt), + ("expect", "candidate evaluation visible"), + ("expect", "parallel interrupt input ready"), + ("send-esc", "\x1b"), + ("expect", "parallel interrupt text input ready"), + ("paste-image-fixture", "rollback-interrupt"), + ("submit-image", "\r"), + ("expect", "post-rollback pipeline progress visible"), + ("expect", "post-rollback security group target visible"), + ("sendline", "/exit"), + ] + assert ("sendline", args.rollback_prompt) not in actions + + def test_rollback_step3_sends_rollback_prompt_without_waiting_for_visible_interrupt( monkeypatch, tmp_path: Path ) -> None: diff --git a/tests/test_agent/test_image_block.py b/tests/test_agent/test_image_block.py index cd2b5c82..43bb33b5 100644 --- a/tests/test_agent/test_image_block.py +++ b/tests/test_agent/test_image_block.py @@ -10,7 +10,20 @@ def test_image_block_serializes_round_trip(): block = ImageBlock(media_type="image/png", data="aGVsbG8=") assert block.type == "image" payload = block.model_dump() - assert payload == {"type": "image", "media_type": "image/png", "data": "aGVsbG8="} + assert payload == {"type": "image", "media_type": "image/png", "data": "aGVsbG8=", "ref_id": None} + + +def test_message_with_image_blocks_deserializes_round_trip(): + msg = Message( + role="user", + content=[TextBlock(text="see"), ImageBlock(media_type="image/png", data="aGVsbG8=")], + ) + + loaded = Message.from_dict(msg.to_dict()) + + assert loaded == msg + assert isinstance(loaded.content, list) + assert isinstance(loaded.content[1], ImageBlock) def test_message_with_blocks_to_api_format_keeps_image(): @@ -24,6 +37,7 @@ def test_message_with_blocks_to_api_format_keeps_image(): api = msg.to_api_format() assert api["content"][1]["type"] == "image" assert api["content"][1]["data"] == "x" + assert "ref_id" not in api["content"][1] def test_conversation_add_user_message_accepts_blocks(): diff --git a/tests/ui/core/test_prompt_input.py b/tests/ui/core/test_prompt_input.py index 7a6e1afb..54f003c5 100644 --- a/tests/ui/core/test_prompt_input.py +++ b/tests/ui/core/test_prompt_input.py @@ -520,6 +520,39 @@ def read_key(self): assert aggregator.updated == [("h", 1), ("hi", 2)] assert out.getvalue().endswith("\n") + def test_input_loop_initializes_next_image_id_from_store(self, monkeypatch): + import iac_code.ui.core.prompt_input as prompt_mod + + events = iter([_key("enter")]) + + class FakeCapture: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read_key(self): + return next(events, None) + + class FakeImageStore: + def next_image_id(self): + return 8 + + out = StringIO() + monkeypatch.setattr(prompt_mod, "sys", SimpleNamespace(stdout=out)) + monkeypatch.setattr( + prompt_mod.shutil, + "get_terminal_size", + lambda *args, **kwargs: os.terminal_size((40, 24)), + ) + monkeypatch.setattr("iac_code.ui.core.raw_input.RawInputCapture", FakeCapture) + + inp = make_input(image_store=FakeImageStore()) + + assert inp._input_loop("❯ ") == "" + assert inp.next_paste_id() == 8 + def test_input_loop_returns_none_on_ctrl_c_with_empty_buffer(self, monkeypatch): import iac_code.ui.core.prompt_input as prompt_mod diff --git a/tests/ui/test_renderer_helpers.py b/tests/ui/test_renderer_helpers.py index d7900fbc..ec4de4a5 100644 --- a/tests/ui/test_renderer_helpers.py +++ b/tests/ui/test_renderer_helpers.py @@ -6,7 +6,7 @@ import pytest from rich.console import Console -from iac_code.agent.message import Message, create_recalled_memory_message +from iac_code.agent.message import ImageBlock, Message, TextBlock, create_recalled_memory_message from iac_code.pipeline.engine.cleanup import CLEANUP_PROMPT_METADATA_TYPE from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult from iac_code.tools.read_file import ReadFileTool @@ -36,6 +36,18 @@ def make_console(width: int = 80, height: int = 12) -> Console: ) +def make_link_console(width: int = 80, height: int = 12) -> Console: + return Console( + file=StringIO(), + width=width, + height=height, + force_terminal=True, + color_system="standard", + legacy_windows=False, + _environ={}, + ) + + class DemoTool(Tool): @property def name(self) -> str: @@ -202,6 +214,50 @@ def test_replay_history_hides_pipeline_cleanup_prompt(self): assert "visible answer" in output assert "hidden cleanup prompt" not in output + def test_replay_history_does_not_link_plain_image_refs_without_image_blocks(self): + console = make_link_console() + registry = ToolRegistry() + renderer = Renderer( + console, + registry, + status_callback=lambda: "ready", + image_path_resolver=lambda image_id: f"/tmp/session-image-{image_id}.png", + ) + + renderer.replay_history([Message(role="user", content="see [Image #1]")]) + + output = console.file.getvalue() + assert "[Image #1]" in output + assert "\x1b]8;" not in output + assert "file:///tmp/session-image-1.png" not in output + + def test_replay_history_renders_structured_image_blocks_as_image_refs(self): + console = make_link_console() + registry = ToolRegistry() + renderer = Renderer( + console, + registry, + status_callback=lambda: "ready", + image_block_path_resolver=lambda block: f"/tmp/session-image-{block.ref_id}.png", + ) + + renderer.replay_history( + [ + Message( + role="user", + content=[ + TextBlock(text="see "), + ImageBlock(media_type="image/png", data="aGVsbG8=", ref_id=8), + ], + ) + ] + ) + + output = console.file.getvalue() + assert "see " in output + assert "[Image #8]" in output + assert "file:///tmp/session-image-8.png" in output + def test_any_segment_has_verbose_content(self): renderer = make_renderer() segments = [ diff --git a/tests/ui/test_repl_parallel_auto_approve.py b/tests/ui/test_repl_parallel_auto_approve.py index 98e4c43f..2ec2829c 100644 --- a/tests/ui/test_repl_parallel_auto_approve.py +++ b/tests/ui/test_repl_parallel_auto_approve.py @@ -292,6 +292,79 @@ async def stream(): await asyncio.wait_for(repl._render_parallel_tabs(stream()), timeout=5.0) +@pytest.mark.asyncio +async def test_parallel_tabs_escape_interrupt_forwards_pipeline_user_input(monkeypatch, fake_live): + from iac_code.agent.message import ImageBlock, TextBlock + from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + from iac_code.pipeline.engine.user_input import PipelineUserInput + from iac_code.ui.core.key_event import KeyEvent + + image_input = PipelineUserInput( + content=[ + TextBlock(text="change"), + ImageBlock(media_type="image/png", data="aGVsbG8="), + ], + display_text="change [Image #1]", + has_images=True, + ) + + repl = _make_repl(prompt_result=True) + repl._pipeline_waiting_input = False + repl._read_pipeline_interrupt_input = AsyncMock(return_value=image_input) + repl._handle_mid_pipeline_message = AsyncMock(return_value=(False, "feedback")) + + pause_called = asyncio.Event() + repl._pipeline.pause_agent_loops = MagicMock(side_effect=pause_called.set) + repl._pipeline.resume_agent_loops = MagicMock() + + class FakeCapture: + def __init__(self, *args, **kwargs): + self._sent = False + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read_key(self, timeout): + if self._sent: + if timeout: + time.sleep(min(timeout, 0.05)) + return None + self._sent = True + return KeyEvent(key="escape", char="\x1b") + + monkeypatch.setattr("iac_code.ui.core.raw_input.RawInputCapture", FakeCapture) + + async def stream(): + yield PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=time.time(), + data={ + "sub_pipeline_id": "sub_test_escape", + "candidate_index": 0, + "candidate_name": "方案1", + "total_steps": 1, + "sub_pipeline_name": "test", + }, + ) + await asyncio.wait_for(pause_called.wait(), timeout=1.0) + yield PipelineEvent( + type=PipelineEventType.STEP_COMPLETED, + step_id=None, + timestamp=time.time(), + data={}, + ) + + interrupted = await asyncio.wait_for(repl._render_parallel_tabs(stream()), timeout=5.0) + + assert interrupted is False + repl._read_pipeline_interrupt_input.assert_awaited_once() + repl._handle_mid_pipeline_message.assert_awaited_once_with(image_input, suppress_render=True) + + @pytest.mark.asyncio async def test_parallel_permission_prompt_exception_denies_and_resumes_ui(fake_live, key_reader_tasks): repl = _make_repl(prompt_result=True) diff --git a/tests/ui/test_repl_parallel_tabs_lifecycle.py b/tests/ui/test_repl_parallel_tabs_lifecycle.py index c88457d0..9f3e69b9 100644 --- a/tests/ui/test_repl_parallel_tabs_lifecycle.py +++ b/tests/ui/test_repl_parallel_tabs_lifecycle.py @@ -251,6 +251,7 @@ async def test_user_input_required_escape_empty_input_returns_to_candidate_selec from rich.console import Console from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + from iac_code.pipeline.engine.user_input import PipelineUserInput from iac_code.types.stream_events import DiagramEvent from iac_code.ui.core.key_event import KeyEvent from iac_code.ui.repl import InlineREPL @@ -260,6 +261,9 @@ async def test_user_input_required_escape_empty_input_returns_to_candidate_selec repl.renderer.console = Console(file=StringIO(), width=120, force_terminal=True) repl.store = MagicMock() repl._pipeline_waiting_input = False + repl._read_pipeline_interrupt_input = AsyncMock( + return_value=PipelineUserInput(content="", display_text="", has_images=False) + ) repl._handle_mid_pipeline_message = AsyncMock(return_value=(False, "")) repl._render_pipeline_stream = AsyncMock() @@ -298,7 +302,6 @@ def update(self, *args, **kwargs): key_events = deque( [ ("key:escape:selection", KeyEvent(key="escape", char="\x1b")), - ("key:escape:input_cancel", KeyEvent(key="escape", char="\x1b")), ("key:enter:selection", KeyEvent(key="enter", char="")), ] ) @@ -344,14 +347,14 @@ async def stream(): ) repl._handle_mid_pipeline_message.assert_not_awaited() + repl._read_pipeline_interrupt_input.assert_awaited_once() pipeline.pause_agent_loops.assert_called() pipeline.resume_agent_loops.assert_called() pipeline.resume.assert_called_once() assert resumed_payloads == [{"selected_candidate_name": "c1", "selected_candidate_index": None}] assert events.index("key:escape:selection") < events.index("pause") - assert events.index("pause") < events.index("key:escape:input_cancel") - assert events.index("key:escape:input_cancel") < events.index("resume") + assert events.index("pause") < events.index("resume") assert events.index("resume") < events.index("key:enter:selection") assert events.index("key:enter:selection") < events.index("pipeline_resume") @@ -363,6 +366,7 @@ async def test_user_input_required_hard_interrupt_clears_waiting_flag(monkeypatc from rich.console import Console from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + from iac_code.pipeline.engine.user_input import PipelineUserInput from iac_code.types.stream_events import DiagramEvent from iac_code.ui.core.key_event import KeyEvent from iac_code.ui.repl import InlineREPL @@ -372,6 +376,8 @@ async def test_user_input_required_hard_interrupt_clears_waiting_flag(monkeypatc repl.renderer.console = Console(file=StringIO(), width=120, force_terminal=True) repl.store = MagicMock() repl._pipeline_waiting_input = False + interrupt_input = PipelineUserInput(content="换", display_text="换", has_images=False) + repl._read_pipeline_interrupt_input = AsyncMock(return_value=interrupt_input) repl._handle_mid_pipeline_message = AsyncMock(return_value=(True, "已切换方案")) repl._render_interrupt_feedback_inline = MagicMock() @@ -399,8 +405,6 @@ def update(self, *args, **kwargs): key_events = deque( [ KeyEvent(key="escape", char="\x1b"), - KeyEvent(key="x", char="换"), - KeyEvent(key="enter", char=""), ] ) @@ -441,11 +445,110 @@ async def stream(): assert result is True assert repl._pipeline_waiting_input is False + repl._handle_mid_pipeline_message.assert_awaited_once_with(interrupt_input, suppress_render=True) pipeline.resume.assert_not_called() pipeline.pause_agent_loops.assert_called() pipeline.resume_agent_loops.assert_called() +@pytest.mark.asyncio +async def test_user_input_required_escape_interrupt_forwards_pipeline_user_input(monkeypatch): + from io import StringIO + + from rich.console import Console + + from iac_code.agent.message import ImageBlock, TextBlock + from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + from iac_code.pipeline.engine.user_input import PipelineUserInput + from iac_code.types.stream_events import DiagramEvent + from iac_code.ui.core.key_event import KeyEvent + from iac_code.ui.repl import InlineREPL + + image_input = PipelineUserInput( + content=[ + TextBlock(text="change"), + ImageBlock(media_type="image/png", data="aGVsbG8="), + ], + display_text="change [Image #1]", + has_images=True, + ) + + repl = InlineREPL.__new__(InlineREPL) + repl.renderer = MagicMock() + repl.renderer.console = Console(file=StringIO(), width=120, force_terminal=True) + repl.store = MagicMock() + repl._pipeline_waiting_input = False + repl._read_pipeline_interrupt_input = AsyncMock(return_value=image_input) + repl._handle_mid_pipeline_message = AsyncMock(return_value=(False, "feedback")) + repl._render_pipeline_stream = AsyncMock() + + pipeline = MagicMock() + pipeline.resume = MagicMock(return_value=MagicMock(name="resumed_stream_after_selection")) + pipeline.pause_agent_loops = MagicMock() + pipeline.resume_agent_loops = MagicMock() + repl._pipeline = pipeline + + class FakeLive: + def __init__(self, *args, **kwargs): + pass + + def start(self): + pass + + def stop(self): + pass + + def update(self, *args, **kwargs): + pass + + monkeypatch.setattr("iac_code.ui.repl.Live", FakeLive) + + key_events = deque( + [ + KeyEvent(key="escape", char="\x1b"), + KeyEvent(key="enter", char=""), + ] + ) + + class FakeCapture: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read_key(self, timeout): + deadline = time.time() + (timeout if timeout else 1.0) + while time.time() < deadline: + if repl._pipeline_waiting_input and key_events: + return key_events.popleft() + time.sleep(0.01) + return None + + monkeypatch.setattr("iac_code.ui.core.raw_input.RawInputCapture", FakeCapture) + + async def stream(): + yield DiagramEvent( + candidate_name="c1", + template_content="ROSTemplateFormatVersion: '2015-09-01'", + mermaid_source="graph TD; A-->B", + ) + yield PipelineEvent( + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id=None, + timestamp=time.time(), + data={"candidates": [{"name": "c1"}]}, + ) + + await asyncio.wait_for(repl._render_candidate_selection_tabs(stream()), timeout=5.0) + + repl._read_pipeline_interrupt_input.assert_awaited_once() + repl._handle_mid_pipeline_message.assert_awaited_once_with(image_input, suppress_render=True) + + @pytest.mark.asyncio async def test_user_input_required_ctrl_c_cancels_candidate_selection(monkeypatch): """Ctrl+C inside the candidate-selection UI should abort the pipeline, diff --git a/tests/ui/test_repl_pipeline_handoff.py b/tests/ui/test_repl_pipeline_handoff.py index bbffb133..4610dcad 100644 --- a/tests/ui/test_repl_pipeline_handoff.py +++ b/tests/ui/test_repl_pipeline_handoff.py @@ -22,6 +22,7 @@ create_cleanup_prompt_message, ) from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.pipeline.engine.user_input import PipelineUserInput from iac_code.types.stream_events import StackProgressEvent, ToolResultEvent, ToolUseEndEvent @@ -1642,7 +1643,7 @@ async def test_handoff_injection_failure_still_switches_to_normal_and_preserves_ ) pipeline.clear_sidecar.assert_not_called() pipeline.mark_user_aborted.assert_not_called() - pipeline.resume.assert_called_once_with("start") + pipeline.resume.assert_called_once_with(PipelineUserInput(content="start", display_text="start", has_images=False)) pipeline.run.assert_not_called() assert repl._pipeline is None assert repl._pipeline_waiting_input is False diff --git a/tests/ui/test_repl_pipeline_image_warning.py b/tests/ui/test_repl_pipeline_image_warning.py index 6e14fff1..45c28653 100644 --- a/tests/ui/test_repl_pipeline_image_warning.py +++ b/tests/ui/test_repl_pipeline_image_warning.py @@ -1,64 +1,141 @@ -"""U-I4: pipeline mode must warn before dropping pasted images.""" +"""Pipeline REPL input should preserve image blocks while keeping text-only feedback.""" from __future__ import annotations +from io import StringIO from unittest.mock import AsyncMock, MagicMock import pytest +from rich.console import Console +from iac_code.agent.message import ImageBlock, TextBlock from iac_code.pipeline.config import RunMode +from iac_code.pipeline.engine.interrupt import InterruptVerdict +from iac_code.pipeline.engine.user_input import PipelineUserInput from iac_code.ui.core.prompt_input import PromptInputResult from iac_code.utils.image.pasted_content import PastedContent -@pytest.mark.asyncio -async def test_pipeline_mode_warns_when_pasted_image_present(monkeypatch): - """Image in pasted_contents should trigger a yellow print_system_message before drop.""" - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - +def _image_prompt(text: str = "describe [Image #1]") -> PromptInputResult: pc = PastedContent(id=1, type="image", content="iVBORw0KGgo=", media_type="image/png") - user_input = PromptInputResult(text="describe this image", pasted_contents={1: pc}) + return PromptInputResult(text=text, pasted_contents={1: pc}) + +@pytest.mark.asyncio +async def test_pipeline_prompt_input_forwards_image_blocks() -> None: from iac_code.ui.repl import InlineREPL repl = InlineREPL.__new__(InlineREPL) repl._runtime_mode = RunMode.PIPELINE - repl.renderer = MagicMock() repl._handle_pipeline_chat = AsyncMock() - await repl._handle_chat(user_input) + await repl._handle_chat(_image_prompt()) - # Verify the warning went to renderer.print_system_message (consistent with - # other image warnings at repl.py:2250, 2258, 2275). - repl.renderer.print_system_message.assert_called_once() - call = repl.renderer.print_system_message.call_args - # First positional arg is the message (or `msg` kwarg). - msg_arg = call.args[0] if call.args else call.kwargs.get("msg") or call.kwargs.get("message") - assert msg_arg is not None, f"could not extract message from call: {call!r}" - assert "image" in msg_arg.lower(), f"image warning text missing: {msg_arg!r}" - # Style must be yellow. - assert call.kwargs.get("style") == "yellow", f"expected style='yellow', got {call.kwargs!r}" - - # Pipeline handler still invoked (warning is non-blocking). repl._handle_pipeline_chat.assert_awaited_once() + pipeline_input = repl._handle_pipeline_chat.await_args.args[0] + assert isinstance(pipeline_input, PipelineUserInput) + assert pipeline_input.display_text == "describe [Image #1]" + assert pipeline_input.has_images is True + assert isinstance(pipeline_input.content, list) + assert any(isinstance(block, ImageBlock) for block in pipeline_input.content) + assert any(isinstance(block, TextBlock) for block in pipeline_input.content) @pytest.mark.asyncio -async def test_pipeline_mode_no_warning_when_no_images(monkeypatch): - """Text-only pipeline input should NOT trigger the image warning.""" - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - - pc = PastedContent(id=1, type="text", content="some pasted text") - user_input = PromptInputResult(text="hi", pasted_contents={1: pc}) - +async def test_pipeline_prompt_input_uses_plain_text_when_no_images() -> None: from iac_code.ui.repl import InlineREPL repl = InlineREPL.__new__(InlineREPL) repl._runtime_mode = RunMode.PIPELINE - repl.renderer = MagicMock() repl._handle_pipeline_chat = AsyncMock() + user_input = PromptInputResult( + text="hi", + pasted_contents={1: PastedContent(id=1, type="text", content="some pasted text")}, + ) await repl._handle_chat(user_input) - repl.renderer.print_system_message.assert_not_called() - repl._handle_pipeline_chat.assert_awaited_once() + pipeline_input = repl._handle_pipeline_chat.await_args.args[0] + assert isinstance(pipeline_input, PipelineUserInput) + assert pipeline_input.content == "hi" + assert pipeline_input.display_text == "hi" + assert pipeline_input.has_images is False + + +def test_pipeline_visible_user_turn_persists_image_blocks_for_resume() -> None: + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + injected = {"role": "user", "content": "visible"} + repl._agent_loop = MagicMock() + repl._agent_loop.context_manager.add_raw_message = MagicMock(return_value=injected) + repl._session_storage = MagicMock() + repl._original_cwd = "/tmp/project" + repl._session_id = "session-1" + repl.current_git_branch = MagicMock(return_value="branch") + pipeline_input = PipelineUserInput( + content=[ + TextBlock(text="describe "), + ImageBlock(media_type="image/png", data="base64-bytes"), + ], + display_text="describe [Image #1]", + has_images=True, + ) + + repl._persist_pipeline_visible_user_turn(pipeline_input) + + raw_message = repl._agent_loop.context_manager.add_raw_message.call_args.args[0] + assert raw_message["role"] == "user" + assert isinstance(raw_message["content"], list) + assert any(isinstance(block, TextBlock) for block in raw_message["content"]) + assert any(isinstance(block, ImageBlock) for block in raw_message["content"]) + repl._session_storage.append.assert_called_once_with( + "/tmp/project", + "session-1", + injected, + git_branch="branch", + ) + + +@pytest.mark.asyncio +async def test_pipeline_mid_interrupt_forwards_image_blocks() -> None: + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl.console = Console(file=StringIO(), width=120, force_terminal=True) + verdict = InterruptVerdict(action="continue", reason="") + repl._pipeline = MagicMock() + repl._pipeline.handle_user_interrupt = AsyncMock(return_value=verdict) + + _needs_restart, feedback = await repl._handle_mid_pipeline_message(_image_prompt(), suppress_render=True) + + pipeline_input = repl._pipeline.handle_user_interrupt.await_args.args[0] + assert isinstance(pipeline_input, PipelineUserInput) + assert pipeline_input.display_text == "describe [Image #1]" + assert pipeline_input.has_images is True + assert isinstance(pipeline_input.content, list) + assert any(isinstance(block, ImageBlock) for block in pipeline_input.content) + assert "describe [Image #1]" in feedback + assert "iVBORw0KGgo=" not in feedback + + +@pytest.mark.asyncio +async def test_pipeline_interrupt_reader_preserves_pasted_images() -> None: + from iac_code.ui.repl import InlineREPL + + class Prompt: + async def get_input(self, *, prompt: str, transient: bool): + return "describe [Image #1]" + + def make_result(self): + return _image_prompt() + + repl = InlineREPL.__new__(InlineREPL) + repl._prompt_input = Prompt() + + pipeline_input = await repl._read_pipeline_interrupt_input() + + assert pipeline_input.display_text == "describe [Image #1]" + assert pipeline_input.has_images is True + assert isinstance(pipeline_input.content, list) + assert any(isinstance(block, ImageBlock) for block in pipeline_input.content) diff --git a/tests/ui/test_repl_pipeline_sidecar_restore.py b/tests/ui/test_repl_pipeline_sidecar_restore.py index d27897fd..1dc95293 100644 --- a/tests/ui/test_repl_pipeline_sidecar_restore.py +++ b/tests/ui/test_repl_pipeline_sidecar_restore.py @@ -3,6 +3,7 @@ import pytest from iac_code.agent.message import Message +from iac_code.pipeline.engine.user_input import PipelineUserInput @pytest.fixture @@ -141,7 +142,9 @@ async def test_restored_waiting_input_routes_current_message_to_resume(monkeypat with patch("iac_code.pipeline.create_pipeline", return_value=pipeline): await repl_for_sidecar_restore._handle_pipeline_chat("方案一") - pipeline.resume.assert_called_once_with("方案一") + pipeline.resume.assert_called_once_with( + PipelineUserInput(content="方案一", display_text="方案一", has_images=False) + ) pipeline.run.assert_not_called() pipeline.continue_from_sidecar.assert_not_called() @@ -346,7 +349,9 @@ async def test_restored_running_routes_to_continue_without_user_prompt(monkeypat with patch("iac_code.pipeline.create_pipeline", return_value=pipeline): await repl_for_sidecar_restore._handle_pipeline_chat("hello after crash") - pipeline.continue_from_sidecar.assert_called_once_with(user_input="hello after crash") + pipeline.continue_from_sidecar.assert_called_once_with( + user_input=PipelineUserInput(content="hello after crash", display_text="hello after crash", has_images=False) + ) pipeline.run.assert_not_called() pipeline.resume.assert_not_called() @@ -375,7 +380,9 @@ async def test_restored_running_uses_pipeline_working_directory(monkeypatch, tmp await repl_for_sidecar_restore._handle_pipeline_chat("hello after crash") pipeline.restore_from_sidecar.assert_awaited_once() - pipeline.continue_from_sidecar.assert_called_once_with(user_input="hello after crash") + pipeline.continue_from_sidecar.assert_called_once_with( + user_input=PipelineUserInput(content="hello after crash", display_text="hello after crash", has_images=False) + ) pipeline.run.assert_not_called() pipeline.resume.assert_not_called() @@ -394,7 +401,7 @@ async def test_corrupt_sidecar_starts_fresh(monkeypatch, repl_for_sidecar_restor with patch("iac_code.pipeline.create_pipeline", return_value=pipeline): await repl_for_sidecar_restore._handle_pipeline_chat("fresh") - pipeline.run.assert_called_once_with("fresh") + pipeline.run.assert_called_once_with(PipelineUserInput(content="fresh", display_text="fresh", has_images=False)) repl_for_sidecar_restore.renderer.print_system_message.assert_called() @@ -413,7 +420,7 @@ async def test_discarded_sidecar_starts_fresh_without_restore(monkeypatch, repl_ await repl_for_sidecar_restore._handle_pipeline_chat("fresh") pipeline.restore_from_sidecar.assert_not_called() - pipeline.run.assert_called_once_with("fresh") + pipeline.run.assert_called_once_with(PipelineUserInput(content="fresh", display_text="fresh", has_images=False)) def _seed_sidecar(repl, status: str) -> None: diff --git a/tests/ui/test_repl_runtime_mode.py b/tests/ui/test_repl_runtime_mode.py index f428476a..cbaab96e 100644 --- a/tests/ui/test_repl_runtime_mode.py +++ b/tests/ui/test_repl_runtime_mode.py @@ -7,6 +7,7 @@ import pytest from iac_code.pipeline.config import RunMode +from iac_code.pipeline.engine.user_input import PipelineUserInput def _make_repl_for_normal_chat(): @@ -35,7 +36,9 @@ async def test_handle_chat_uses_instance_runtime_mode_when_environment_is_normal await repl._handle_chat("hello") - repl._handle_pipeline_chat.assert_awaited_once_with("hello") + repl._handle_pipeline_chat.assert_awaited_once_with( + PipelineUserInput(content="hello", display_text="hello", has_images=False) + ) repl._agent_loop.run_streaming.assert_not_called() diff --git a/tests/ui/test_repl_swap_session_pipeline.py b/tests/ui/test_repl_swap_session_pipeline.py index 3db133d4..e731328b 100644 --- a/tests/ui/test_repl_swap_session_pipeline.py +++ b/tests/ui/test_repl_swap_session_pipeline.py @@ -6,6 +6,7 @@ import pytest from iac_code.pipeline.config import RunMode +from iac_code.pipeline.engine.user_input import PipelineUserInput def _make_repl_with_pipeline(tmp_path: Path, session_id_old: str, session_id_new: str): @@ -297,7 +298,9 @@ async def test_swap_session_running_resume_routes_next_message_to_interrupt_judg await repl._handle_pipeline_chat("change the plan") - fake_pipeline.continue_from_sidecar.assert_called_once_with(user_input="change the plan") + fake_pipeline.continue_from_sidecar.assert_called_once_with( + user_input=PipelineUserInput(content="change the plan", display_text="change the plan", has_images=False) + ) fake_pipeline.resume.assert_not_called() @@ -337,7 +340,9 @@ async def test_swap_session_waiting_input_resume_routes_next_message_to_resume(t await repl._handle_pipeline_chat("option A") - fake_pipeline.resume.assert_called_once_with("option A") + fake_pipeline.resume.assert_called_once_with( + PipelineUserInput(content="option A", display_text="option A", has_images=False) + ) fake_pipeline.continue_from_sidecar.assert_not_called() diff --git a/tests/utils/image/test_processor.py b/tests/utils/image/test_processor.py index d247696e..9adc5dca 100644 --- a/tests/utils/image/test_processor.py +++ b/tests/utils/image/test_processor.py @@ -26,6 +26,7 @@ def test_image_at_arbitrary_position_produces_interleaved_blocks(): assert len(blocks) == 3 assert isinstance(blocks[0], TextBlock) and blocks[0].text == "look at " assert isinstance(blocks[1], ImageBlock) + assert blocks[1].ref_id == 1 assert isinstance(blocks[2], TextBlock) and blocks[2].text == " please" diff --git a/tests/utils/image/test_store.py b/tests/utils/image/test_store.py index 277989e2..9de65473 100644 --- a/tests/utils/image/test_store.py +++ b/tests/utils/image/test_store.py @@ -24,6 +24,28 @@ def test_store_writes_per_session_file_with_0o600(tmp_path, monkeypatch): assert stat.S_IMODE(p.stat().st_mode) == 0o600 +def test_get_path_discovers_cached_file_after_store_recreated(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") + first = ImageStore(session_id="sess-a") + pc = PastedContent(id=7, type="image", content="aGVsbG8=", media_type="image/png") + path = first.store(pc) + assert path is not None + + restored = ImageStore(session_id="sess-a") + + assert restored.get_path(7) == path + + +def test_next_image_id_skips_existing_cached_files_after_store_recreated(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") + first = ImageStore(session_id="sess-a") + assert first.store(PastedContent(id=1, type="image", content="MQ==", media_type="image/png")) is not None + + restored = ImageStore(session_id="sess-a") + + assert restored.next_image_id() == 2 + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX modes are not meaningful on Windows") def test_store_directories_are_owner_only(tmp_path, monkeypatch): monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") From 1e9536e04685852b2bcfdfd3b2c1d75e442b0c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 22 Jun 2026 15:00:07 +0800 Subject: [PATCH 08/59] fix: refine pipeline memory policy - include available memory index when read_memory receives an unknown name - stop injecting full auto-memory content into pipeline step prompts - keep pipeline step AgentLoop free of memory side recall - make selling pipeline memory tool policy explicit - guide intent and architecture steps to read memory when relevant --- src/iac_code/memory/memory_tools.py | 12 +++++- src/iac_code/pipeline/selling/pipeline.yaml | 12 ++++-- .../selling/prompts/architecture_planning.md | 4 +- .../selling/prompts/intent_parsing.md | 5 ++- src/iac_code/ui/repl.py | 20 +++++---- tests/memory/test_memory_tools.py | 22 ++++++++++ .../engine/test_step_executor_integration.py | 22 ++++++++++ .../test_iac_aliyun_architecture_skill.py | 10 +++++ .../skills/test_iac_aliyun_intent_skill.py | 10 +++++ tests/pipeline/selling/test_memory_policy.py | 42 +++++++++++++++++++ tests/ui/test_repl_pipeline_memory.py | 20 +++++++++ 11 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 tests/pipeline/selling/test_memory_policy.py create mode 100644 tests/ui/test_repl_pipeline_memory.py diff --git a/src/iac_code/memory/memory_tools.py b/src/iac_code/memory/memory_tools.py index c634fda1..c6b87f82 100644 --- a/src/iac_code/memory/memory_tools.py +++ b/src/iac_code/memory/memory_tools.py @@ -40,7 +40,17 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> if name: mem = self._manager.load(name) if mem is None: - return ToolResult.error(_("Memory '{name}' not found.").format(name=name)) + base = _("Memory '{name}' not found.").format(name=name) + index = self._manager.get_index_content() + if index: + return ToolResult.error( + _( + "{base}\n\n" + "Available memories:\n{index}\n\n" + "Call read_memory again with one of these names, or omit name to list all memories." + ).format(base=base, index=index.rstrip()) + ) + return ToolResult.error("{base}\n\n{empty}".format(base=base, empty=_("No memories saved yet."))) return ToolResult.success(f"[{mem.get('type', '')}] {mem.get('description', '')}\n\n{mem['content']}") else: index = self._manager.get_index_content() diff --git a/src/iac_code/pipeline/selling/pipeline.yaml b/src/iac_code/pipeline/selling/pipeline.yaml index b18161fe..821f67d5 100644 --- a/src/iac_code/pipeline/selling/pipeline.yaml +++ b/src/iac_code/pipeline/selling/pipeline.yaml @@ -71,6 +71,9 @@ sub_pipelines: skill: iac-aliyun-template-generating prompt: prompts/template_generating.md context_fields: [candidate] + tools: + include: [] + exclude: [write_memory] - id: reviewing description: "审查模板的安全性和最佳实践" @@ -100,7 +103,7 @@ sub_pipelines: context_fields: [template] tools: include: [] - exclude: [bash] + exclude: [bash, write_memory] steps: - id: intent_parsing @@ -113,7 +116,7 @@ steps: field: is_infra_intent value: false tools: - include: [-] + include: [read_memory] exclude: [] inject_tools: [ask_user_question] completion_guards: @@ -181,7 +184,7 @@ steps: prompt: prompts/architecture_planning.md context_fields: [intent] tools: - include: [read_file, list_files, glob, grep, web_fetch, aliyun_doc_search] + include: [read_memory, read_file, list_files, glob, grep, web_fetch, aliyun_doc_search] exclude: [] rollback: - target: intent_parsing @@ -251,6 +254,9 @@ steps: interrupt_judge_failure: pause context_fields: [intent, selected_plan, evaluated_candidates] hooks_file: hooks/deploying.py + tools: + include: [] + exclude: [write_memory] rollback: - target: confirm_and_select condition: invalid_selection diff --git a/src/iac_code/pipeline/selling/prompts/architecture_planning.md b/src/iac_code/pipeline/selling/prompts/architecture_planning.md index b8af59bb..0c993896 100644 --- a/src/iac_code/pipeline/selling/prompts/architecture_planning.md +++ b/src/iac_code/pipeline/selling/prompts/architecture_planning.md @@ -22,6 +22,8 @@ - 示例:`templates/1-simple-nginx.yml`、`templates/2-high-availability-slb.yml` ## 注意事项 -- 不要读取项目文件或记忆,所需的上下文已在上方提供。 +- 不要读取项目文件,所需的主要上下文已在上方提供。 +- 你可以按需自主使用 `read_memory` 补充规划上下文:在生成方案前,如用户意图涉及已有资源、默认地域、已有 VPC/Zone、网段约束、成本偏好、高可用偏好、架构偏好、命名规范或历史项目约束,先调用 `read_memory({})` 查看索引,再读取相关 name。 +- 记忆只用于补充方案设计背景;若记忆与当前用户意图冲突,以当前用户意图为准。 - 直接根据已知意图设计架构方案。 - 如果意图信息不足以设计架构,可在 rollback_request 中请求回退到 intent_parsing。 diff --git a/src/iac_code/pipeline/selling/prompts/intent_parsing.md b/src/iac_code/pipeline/selling/prompts/intent_parsing.md index 7ec7754d..acb756d9 100644 --- a/src/iac_code/pipeline/selling/prompts/intent_parsing.md +++ b/src/iac_code/pipeline/selling/prompts/intent_parsing.md @@ -14,7 +14,10 @@ - **阿里云基础设施需求**:信息足够时直接调用 `complete_step`,不需要额外文字输出。 ## 注意事项 -- 不要读取项目文件或记忆,用户的需求已经在下一条消息中。 +- 不要读取项目文件,用户的需求已经在下一条消息中。 +- 你可以按需自主使用 `read_memory` 辅助理解用户上下文:当用户提到已有资源、资源命名、VPC/网络、地域、预算、可用性偏好、历史项目或“沿用之前配置”等线索时,先调用 `read_memory({})` 查看索引,再按相关 name 读取具体记忆。 +- 记忆只作为辅助上下文,不能替代当前用户输入和必要澄清;若记忆与当前用户输入冲突,以当前用户输入为准。 +- 不要因为没有相关记忆而阻塞,也不要为了读记忆而跳过必须的 `ask_user_question` 澄清。 - 直接根据用户描述进行分析;如遇 low 置信度或非基础设施但可引导的输入,必须先调用 `ask_user_question` 让用户补充或选择方向。 - 对“帮我做个网站”“我有个项目想上线”“做个小程序/应用”这类没有明确阿里云资源,且缺少足够部署约束、规模、预算或可用性信息的输入,直接调用 `complete_step` 视为错误;必须先调用 `ask_user_question`。 - 不要询问用户是否要使用 IaC,也不要问“是否转成 IaC”。这个 pipeline 默认处理部署/云资源方案;提问应帮助用户把模糊意图变清晰。 diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index 2428ed39..724e211f 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -937,6 +937,16 @@ def _is_pipeline_safe_command(self, user_input: str) -> bool: first = user_input.split(None, 1)[0] if user_input else "" return first in _PIPELINE_SAFE_COMMANDS + def _pipeline_memory_content_getter(self) -> None: + """Return pipeline prompt memory provider. + + Pipeline steps should not receive all auto-memory topic bodies in the + system prompt. They also intentionally do not receive MemoryRecallService, + so no side recall is triggered. Relevant topic memories are available + through the explicit read_memory tool when a step's tool policy allows it. + """ + return None + def _maybe_block_user_escape(self, user_input: str) -> bool: """Return True if the input is a gated escape and we should NOT process it. @@ -2289,7 +2299,7 @@ async def ensure_pipeline_restored_for_prompt(self) -> bool: session_id=self._session_id, cwd=pipeline_cwd, permission_context_getter=lambda: self.store.get_state().permission_context, - memory_content_getter=(lambda: self._memory_manager.get_prompt_content() if self._memory_manager else ""), + memory_content_getter=self._pipeline_memory_content_getter(), auto_trigger_skills=self.command_registry.get_model_invocable_skills(), resume_from_sidecar=True, ) @@ -2359,9 +2369,7 @@ async def _handle_pipeline_chat(self, user_input: str | "PipelineUserInput") -> session_id=self._session_id, cwd=pipeline_cwd, permission_context_getter=lambda: self.store.get_state().permission_context, - memory_content_getter=( - lambda: self._memory_manager.get_prompt_content() if self._memory_manager else "" - ), + memory_content_getter=self._pipeline_memory_content_getter(), auto_trigger_skills=self.command_registry.get_model_invocable_skills(), ) self._refresh_pipeline_display_recorder() @@ -4587,9 +4595,7 @@ async def swap_session_async(self, new_session_id: str) -> None: session_id=new_session_id, cwd=pipeline_cwd, permission_context_getter=lambda: self.store.get_state().permission_context, - memory_content_getter=( - lambda: self._memory_manager.get_prompt_content() if self._memory_manager else "" - ), + memory_content_getter=self._pipeline_memory_content_getter(), auto_trigger_skills=self.command_registry.get_model_invocable_skills(), resume_from_sidecar=True, ) diff --git a/tests/memory/test_memory_tools.py b/tests/memory/test_memory_tools.py index 5edf180a..ac7635f9 100644 --- a/tests/memory/test_memory_tools.py +++ b/tests/memory/test_memory_tools.py @@ -63,6 +63,28 @@ async def test_read_missing_memory_returns_error(self): assert result.is_error is True assert "not found" in result.content + async def test_read_missing_memory_returns_error_with_index_content(self): + manager = FakeMemoryManager() + manager.index_content = "- [role](role.md) — Role\n- [prefs](prefs.md) — Preferences\n" + tool = ReadMemoryTool(manager) + + result = await tool.execute(tool_input={"name": "missing"}, context=ToolContext()) + + assert result.is_error is True + assert "Memory 'missing' not found." in result.content + assert "Available memories:" in result.content + assert "- [role](role.md) — Role" in result.content + assert "- [prefs](prefs.md) — Preferences" in result.content + assert "Call read_memory again with one of these names" in result.content + + async def test_read_missing_memory_returns_error_with_empty_index_message(self): + tool = ReadMemoryTool(FakeMemoryManager()) + + result = await tool.execute(tool_input={"name": "missing"}, context=ToolContext()) + + assert result.is_error is True + assert result.content == "Memory 'missing' not found.\n\nNo memories saved yet." + async def test_read_without_name_returns_index_content(self): manager = FakeMemoryManager() manager.index_content = "memory index" diff --git a/tests/pipeline/engine/test_step_executor_integration.py b/tests/pipeline/engine/test_step_executor_integration.py index cb971a7f..8150b1ab 100644 --- a/tests/pipeline/engine/test_step_executor_integration.py +++ b/tests/pipeline/engine/test_step_executor_integration.py @@ -108,3 +108,25 @@ def test_step_executor_defaults_keep_existing_signatures(tmp_path): assert executor._permission_context_getter is None assert executor._memory_content_getter is None assert executor._auto_trigger_skills == [] + + +def test_step_agent_loop_does_not_receive_memory_recall_service(monkeypatch, tmp_path): + captured_kwargs = {} + + class FakeAgentLoop: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + monkeypatch.setattr("iac_code.agent.agent_loop.AgentLoop", FakeAgentLoop) + + executor = _make_executor( + tmp_path, + memory_content_getter=lambda: "this should not imply side recall", + ) + step = _make_step() + ctx = PipelineContext({"x": []}) + + agent_context = executor.build_agent_loop_context(step, ctx, "session-1") + + assert agent_context.agent_loop is not None + assert "memory_recall_service" not in captured_kwargs diff --git a/tests/pipeline/selling/skills/test_iac_aliyun_architecture_skill.py b/tests/pipeline/selling/skills/test_iac_aliyun_architecture_skill.py index 0a491802..c29f5980 100644 --- a/tests/pipeline/selling/skills/test_iac_aliyun_architecture_skill.py +++ b/tests/pipeline/selling/skills/test_iac_aliyun_architecture_skill.py @@ -24,3 +24,13 @@ def test_architecture_consumes_intent_resource_lifecycle_contract(): assert "use_existing/reference 必须作为已有资源引用" in body assert "不得生成 VSwitch" in body assert "forbidden_resources" not in body + + +def test_architecture_prompt_guides_optional_memory_lookup_for_planning_context(): + body = PROMPT_FILE.read_text(encoding="utf-8") + + assert "不要读取项目文件或记忆" not in body + assert "read_memory({})" in body + assert "架构偏好" in body + assert "已有 VPC" in body + assert "当前用户意图为准" in body diff --git a/tests/pipeline/selling/skills/test_iac_aliyun_intent_skill.py b/tests/pipeline/selling/skills/test_iac_aliyun_intent_skill.py index 99f28083..f7b64857 100644 --- a/tests/pipeline/selling/skills/test_iac_aliyun_intent_skill.py +++ b/tests/pipeline/selling/skills/test_iac_aliyun_intent_skill.py @@ -49,6 +49,16 @@ def test_intent_prompt_requires_question_before_completion_for_ambiguous_guidabl assert "不要把 `is_infra_intent`" in body +def test_intent_prompt_guides_optional_memory_lookup_without_overriding_current_input(): + body = PROMPT_FILE.read_text(encoding="utf-8") + + assert "不要读取项目文件或记忆" not in body + assert "read_memory({})" in body + assert "已有资源" in body + assert "当前用户输入为准" in body + assert "不要因为没有相关记忆而阻塞" in body + + def test_intent_prompt_pins_extremely_vague_launch_to_detail_request(): body = PROMPT_FILE.read_text(encoding="utf-8") diff --git a/tests/pipeline/selling/test_memory_policy.py b/tests/pipeline/selling/test_memory_policy.py new file mode 100644 index 00000000..e153c4c0 --- /dev/null +++ b/tests/pipeline/selling/test_memory_policy.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +from iac_code.pipeline.engine.loader import load_pipeline_dir + + +def _selling_dir() -> Path: + return Path(__file__).resolve().parents[3] / "src" / "iac_code" / "pipeline" / "selling" + + +def _step_by_id(step_id: str): + loaded = load_pipeline_dir(_selling_dir()) + return next(step for step in loaded.steps if step.step_id == step_id) + + +def _sub_step_by_id(sub_pipeline_name: str, step_id: str): + loaded = load_pipeline_dir(_selling_dir()) + return next(step for step in loaded.sub_pipelines[sub_pipeline_name].steps if step.step_id == step_id) + + +def test_memory_read_is_available_to_steps_that_need_autonomous_context_choice() -> None: + intent = _step_by_id("intent_parsing") + architecture = _step_by_id("architecture_planning") + + assert intent.tools is not None + assert "read_memory" in intent.tools.include + assert architecture.tools is not None + assert "read_memory" in architecture.tools.include + + +def test_pipeline_steps_do_not_offer_write_memory_by_default() -> None: + template = _sub_step_by_id("evaluate_candidate", "template_generating") + cost = _sub_step_by_id("evaluate_candidate", "cost_estimating") + deploying = _step_by_id("deploying") + + assert template.tools is not None + assert "write_memory" in template.tools.exclude + assert cost.tools is not None + assert "write_memory" in cost.tools.exclude + assert deploying.tools is not None + assert "write_memory" in deploying.tools.exclude diff --git a/tests/ui/test_repl_pipeline_memory.py b/tests/ui/test_repl_pipeline_memory.py new file mode 100644 index 00000000..8f6d12a7 --- /dev/null +++ b/tests/ui/test_repl_pipeline_memory.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + +REPL_SOURCE = Path("src/iac_code/ui/repl.py") + + +def test_repl_pipeline_creation_does_not_pass_full_memory_prompt_content() -> None: + source = REPL_SOURCE.read_text(encoding="utf-8") + + assert "get_prompt_content()" not in source + assert "memory_content_getter=(lambda: self._memory_manager.get_prompt_content()" not in source + assert 'lambda: self._memory_manager.get_prompt_content() if self._memory_manager else ""' not in source + + +def test_repl_pipeline_creation_uses_explicit_pipeline_memory_policy_helper() -> None: + source = REPL_SOURCE.read_text(encoding="utf-8") + + assert "def _pipeline_memory_content_getter(" in source + assert source.count("memory_content_getter=self._pipeline_memory_content_getter(),") == 3 From c904dae9f849520b2bef93665b421c46278fb734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 22 Jun 2026 15:21:48 +0800 Subject: [PATCH 09/59] fix: expose pipeline docs in website navigation --- website/docusaurus.config.ts | 9 ++++ .../de/docusaurus-theme-classic/footer.json | 4 ++ .../de/docusaurus-theme-classic/navbar.json | 4 ++ .../es/docusaurus-theme-classic/footer.json | 4 ++ .../es/docusaurus-theme-classic/navbar.json | 4 ++ .../fr/docusaurus-theme-classic/footer.json | 4 ++ .../fr/docusaurus-theme-classic/navbar.json | 4 ++ .../ja/docusaurus-theme-classic/footer.json | 4 ++ .../ja/docusaurus-theme-classic/navbar.json | 4 ++ .../pt/docusaurus-theme-classic/footer.json | 4 ++ .../pt/docusaurus-theme-classic/navbar.json | 4 ++ .../docusaurus-theme-classic/footer.json | 4 ++ .../docusaurus-theme-classic/navbar.json | 4 ++ .../src/clientModules/docsNavigation.test.cjs | 42 +++++++++++++++++++ 14 files changed, 99 insertions(+) create mode 100644 website/src/clientModules/docsNavigation.test.cjs diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 37f298b4..7e90b0d1 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -110,6 +110,11 @@ const config: Config = { label: 'CLI', position: 'left', }, + { + to: '/docs/automation/pipeline-mode', + label: 'Pipeline', + position: 'left', + }, { href: 'https://github.com/aliyun/iac-code', label: 'GitHub', @@ -135,6 +140,10 @@ const config: Config = { label: 'CLI Overview', to: '/docs/cli/usage', }, + { + label: 'Pipeline Mode', + to: '/docs/automation/pipeline-mode', + }, { label: 'Slash Commands', to: '/docs/cli/commands', diff --git a/website/i18n/de/docusaurus-theme-classic/footer.json b/website/i18n/de/docusaurus-theme-classic/footer.json index 05a8961b..d3581b02 100644 --- a/website/i18n/de/docusaurus-theme-classic/footer.json +++ b/website/i18n/de/docusaurus-theme-classic/footer.json @@ -11,6 +11,10 @@ "message": "CLI-Uebersicht", "description": "The label of footer link with label=CLI Overview linking to /docs/cli/usage" }, + "link.item.label.Pipeline Mode": { + "message": "Pipeline-Modus", + "description": "The label of footer link with label=Pipeline Mode linking to /docs/automation/pipeline-mode" + }, "link.item.label.Slash Commands": { "message": "Slash-Befehle", "description": "The label of footer link with label=Slash Commands linking to /docs/cli/commands" diff --git a/website/i18n/de/docusaurus-theme-classic/navbar.json b/website/i18n/de/docusaurus-theme-classic/navbar.json index 88d7385e..2616618e 100644 --- a/website/i18n/de/docusaurus-theme-classic/navbar.json +++ b/website/i18n/de/docusaurus-theme-classic/navbar.json @@ -15,6 +15,10 @@ "message": "CLI", "description": "Navbar item with label CLI" }, + "item.label.Pipeline": { + "message": "Pipeline", + "description": "Navbar item with label Pipeline" + }, "item.label.GitHub": { "message": "GitHub", "description": "Navbar item with label GitHub" diff --git a/website/i18n/es/docusaurus-theme-classic/footer.json b/website/i18n/es/docusaurus-theme-classic/footer.json index 8d7e93f5..bf53da1e 100644 --- a/website/i18n/es/docusaurus-theme-classic/footer.json +++ b/website/i18n/es/docusaurus-theme-classic/footer.json @@ -11,6 +11,10 @@ "message": "Vision general del CLI", "description": "The label of footer link with label=CLI Overview linking to /docs/cli/usage" }, + "link.item.label.Pipeline Mode": { + "message": "Modo Pipeline", + "description": "The label of footer link with label=Pipeline Mode linking to /docs/automation/pipeline-mode" + }, "link.item.label.Slash Commands": { "message": "Comandos slash", "description": "The label of footer link with label=Slash Commands linking to /docs/cli/commands" diff --git a/website/i18n/es/docusaurus-theme-classic/navbar.json b/website/i18n/es/docusaurus-theme-classic/navbar.json index b86da3d4..5c9d595b 100644 --- a/website/i18n/es/docusaurus-theme-classic/navbar.json +++ b/website/i18n/es/docusaurus-theme-classic/navbar.json @@ -15,6 +15,10 @@ "message": "CLI", "description": "Navbar item with label CLI" }, + "item.label.Pipeline": { + "message": "Pipeline", + "description": "Navbar item with label Pipeline" + }, "item.label.GitHub": { "message": "GitHub", "description": "Navbar item with label GitHub" diff --git a/website/i18n/fr/docusaurus-theme-classic/footer.json b/website/i18n/fr/docusaurus-theme-classic/footer.json index 1ec6c8a4..5782c8d2 100644 --- a/website/i18n/fr/docusaurus-theme-classic/footer.json +++ b/website/i18n/fr/docusaurus-theme-classic/footer.json @@ -11,6 +11,10 @@ "message": "Aperçu CLI", "description": "The label of footer link with label=CLI Overview linking to /docs/cli/usage" }, + "link.item.label.Pipeline Mode": { + "message": "Mode Pipeline", + "description": "The label of footer link with label=Pipeline Mode linking to /docs/automation/pipeline-mode" + }, "link.item.label.Slash Commands": { "message": "Commandes slash", "description": "The label of footer link with label=Slash Commands linking to /docs/cli/commands" diff --git a/website/i18n/fr/docusaurus-theme-classic/navbar.json b/website/i18n/fr/docusaurus-theme-classic/navbar.json index fc29c746..ba44e3ed 100644 --- a/website/i18n/fr/docusaurus-theme-classic/navbar.json +++ b/website/i18n/fr/docusaurus-theme-classic/navbar.json @@ -15,6 +15,10 @@ "message": "CLI", "description": "Navbar item with label CLI" }, + "item.label.Pipeline": { + "message": "Pipeline", + "description": "Navbar item with label Pipeline" + }, "item.label.GitHub": { "message": "GitHub", "description": "Navbar item with label GitHub" diff --git a/website/i18n/ja/docusaurus-theme-classic/footer.json b/website/i18n/ja/docusaurus-theme-classic/footer.json index c4d1d35f..b5b37bbb 100644 --- a/website/i18n/ja/docusaurus-theme-classic/footer.json +++ b/website/i18n/ja/docusaurus-theme-classic/footer.json @@ -11,6 +11,10 @@ "message": "CLI 概要", "description": "The label of footer link with label=CLI Overview linking to /docs/cli/usage" }, + "link.item.label.Pipeline Mode": { + "message": "Pipeline モード", + "description": "The label of footer link with label=Pipeline Mode linking to /docs/automation/pipeline-mode" + }, "link.item.label.Slash Commands": { "message": "スラッシュコマンド", "description": "The label of footer link with label=Slash Commands linking to /docs/cli/commands" diff --git a/website/i18n/ja/docusaurus-theme-classic/navbar.json b/website/i18n/ja/docusaurus-theme-classic/navbar.json index b5c3ea04..2fc393e8 100644 --- a/website/i18n/ja/docusaurus-theme-classic/navbar.json +++ b/website/i18n/ja/docusaurus-theme-classic/navbar.json @@ -15,6 +15,10 @@ "message": "CLI", "description": "Navbar item with label CLI" }, + "item.label.Pipeline": { + "message": "Pipeline", + "description": "Navbar item with label Pipeline" + }, "item.label.GitHub": { "message": "GitHub", "description": "Navbar item with label GitHub" diff --git a/website/i18n/pt/docusaurus-theme-classic/footer.json b/website/i18n/pt/docusaurus-theme-classic/footer.json index 4f39ec05..e2af9390 100644 --- a/website/i18n/pt/docusaurus-theme-classic/footer.json +++ b/website/i18n/pt/docusaurus-theme-classic/footer.json @@ -11,6 +11,10 @@ "message": "Visao geral do CLI", "description": "The label of footer link with label=CLI Overview linking to /docs/cli/usage" }, + "link.item.label.Pipeline Mode": { + "message": "Modo pipeline", + "description": "The label of footer link with label=Pipeline Mode linking to /docs/automation/pipeline-mode" + }, "link.item.label.Slash Commands": { "message": "Comandos slash", "description": "The label of footer link with label=Slash Commands linking to /docs/cli/commands" diff --git a/website/i18n/pt/docusaurus-theme-classic/navbar.json b/website/i18n/pt/docusaurus-theme-classic/navbar.json index 098fb5cc..b9a4b5ed 100644 --- a/website/i18n/pt/docusaurus-theme-classic/navbar.json +++ b/website/i18n/pt/docusaurus-theme-classic/navbar.json @@ -15,6 +15,10 @@ "message": "CLI", "description": "Navbar item with label CLI" }, + "item.label.Pipeline": { + "message": "Pipeline", + "description": "Navbar item with label Pipeline" + }, "item.label.GitHub": { "message": "GitHub", "description": "Navbar item with label GitHub" diff --git a/website/i18n/zh-Hans/docusaurus-theme-classic/footer.json b/website/i18n/zh-Hans/docusaurus-theme-classic/footer.json index 900ef5ea..828afa27 100644 --- a/website/i18n/zh-Hans/docusaurus-theme-classic/footer.json +++ b/website/i18n/zh-Hans/docusaurus-theme-classic/footer.json @@ -11,6 +11,10 @@ "message": "CLI 概览", "description": "The label of footer link with label=CLI Overview linking to /docs/cli/usage" }, + "link.item.label.Pipeline Mode": { + "message": "Pipeline 模式", + "description": "The label of footer link with label=Pipeline Mode linking to /docs/automation/pipeline-mode" + }, "link.item.label.Slash Commands": { "message": "Slash 命令", "description": "The label of footer link with label=Slash Commands linking to /docs/cli/commands" diff --git a/website/i18n/zh-Hans/docusaurus-theme-classic/navbar.json b/website/i18n/zh-Hans/docusaurus-theme-classic/navbar.json index 6713df9b..1d24647c 100644 --- a/website/i18n/zh-Hans/docusaurus-theme-classic/navbar.json +++ b/website/i18n/zh-Hans/docusaurus-theme-classic/navbar.json @@ -15,6 +15,10 @@ "message": "CLI", "description": "Navbar item with label CLI" }, + "item.label.Pipeline": { + "message": "Pipeline", + "description": "Navbar item with label Pipeline" + }, "item.label.GitHub": { "message": "GitHub", "description": "Navbar item with label GitHub" diff --git a/website/src/clientModules/docsNavigation.test.cjs b/website/src/clientModules/docsNavigation.test.cjs new file mode 100644 index 00000000..792c342f --- /dev/null +++ b/website/src/clientModules/docsNavigation.test.cjs @@ -0,0 +1,42 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const websiteRoot = path.resolve(__dirname, '../..'); +const pipelineDocPath = '/docs/automation/pipeline-mode'; + +function readWebsiteFile(...segments) { + return fs.readFileSync(path.join(websiteRoot, ...segments), 'utf8'); +} + +function readLocaleJson(locale, fileName) { + return JSON.parse(readWebsiteFile('i18n', locale, 'docusaurus-theme-classic', fileName)); +} + +test('global navigation exposes Pipeline documentation directly', () => { + const config = readWebsiteFile('docusaurus.config.ts'); + + assert.match(config, /label:\s*'Pipeline'/); + assert.match(config, /label:\s*'Pipeline Mode'/); + assert.equal((config.match(new RegExp(`to:\\s*'${pipelineDocPath}'`, 'g')) ?? []).length, 2); +}); + +test('localized navbar and footer include Pipeline documentation labels', () => { + const expected = { + 'zh-Hans': {navbar: 'Pipeline', footer: 'Pipeline 模式'}, + ja: {navbar: 'Pipeline', footer: 'Pipeline モード'}, + fr: {navbar: 'Pipeline', footer: 'Mode Pipeline'}, + de: {navbar: 'Pipeline', footer: 'Pipeline-Modus'}, + es: {navbar: 'Pipeline', footer: 'Modo Pipeline'}, + pt: {navbar: 'Pipeline', footer: 'Modo pipeline'}, + }; + + for (const [locale, labels] of Object.entries(expected)) { + const navbar = readLocaleJson(locale, 'navbar.json'); + const footer = readLocaleJson(locale, 'footer.json'); + + assert.equal(navbar['item.label.Pipeline']?.message, labels.navbar); + assert.equal(footer['link.item.label.Pipeline Mode']?.message, labels.footer); + } +}); From be6139d202fc8df16e866672e24f59375cd630b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 22 Jun 2026 15:46:20 +0800 Subject: [PATCH 10/59] fix: remove static pipeline rollback rules --- src/iac_code/pipeline/engine/__init__.py | 3 +- src/iac_code/pipeline/engine/loader.py | 6 --- .../pipeline/engine/pipeline_runner.py | 2 +- src/iac_code/pipeline/engine/state_machine.py | 20 ++++----- src/iac_code/pipeline/engine/step_executor.py | 5 +-- src/iac_code/pipeline/engine/step_spec.py | 2 - .../pipeline/engine/sub_pipeline_executor.py | 4 +- src/iac_code/pipeline/engine/types.py | 10 ----- src/iac_code/pipeline/selling/pipeline.yaml | 14 ------- tests/pipeline/engine/test_loader.py | 26 ++++++++++++ .../engine/test_pipeline_observability.py | 4 +- tests/pipeline/engine/test_pipeline_runner.py | 3 -- tests/pipeline/engine/test_state_machine.py | 41 +++++-------------- tests/pipeline/engine/test_step_executor.py | 38 ++++++++++++----- tests/pipeline/engine/test_step_spec.py | 4 +- .../engine/test_sub_pipeline_executor.py | 3 +- tests/pipeline/engine/test_types.py | 25 ++--------- .../selling/test_terminal_ui_contract.py | 6 +-- 18 files changed, 86 insertions(+), 130 deletions(-) diff --git a/src/iac_code/pipeline/engine/__init__.py b/src/iac_code/pipeline/engine/__init__.py index 1325b1df..005bcbde 100644 --- a/src/iac_code/pipeline/engine/__init__.py +++ b/src/iac_code/pipeline/engine/__init__.py @@ -11,7 +11,7 @@ from iac_code.pipeline.engine.step_executor import StepExecutor from iac_code.pipeline.engine.step_spec import A2AArtifactSpec, LoadedPipeline, StepSpec, SubPipelineSpec, render_prompt from iac_code.pipeline.engine.sub_pipeline_executor import SubPipelineExecutor, SubPipelineResult -from iac_code.pipeline.engine.types import RollbackRule, StepConfig, StepResult, StepStatus +from iac_code.pipeline.engine.types import StepConfig, StepResult, StepStatus from iac_code.pipeline.engine.ui_contract import PipelineStepType, PipelineUiMode __all__ = [ @@ -27,7 +27,6 @@ "PipelineSession", "PipelineStepType", "PipelineUiMode", - "RollbackRule", "StateMachine", "StepConfig", "StepExecutor", diff --git a/src/iac_code/pipeline/engine/loader.py b/src/iac_code/pipeline/engine/loader.py index 4fc8b308..3ec2a2fc 100644 --- a/src/iac_code/pipeline/engine/loader.py +++ b/src/iac_code/pipeline/engine/loader.py @@ -22,7 +22,6 @@ StepSpec, SubPipelineSpec, ) -from iac_code.pipeline.engine.types import RollbackRule logger = logging.getLogger(__name__) @@ -195,10 +194,6 @@ def _parse_sub_pipelines( def _parse_steps(raw_steps: list[dict]) -> list[StepSpec]: steps: list[StepSpec] = [] for raw in raw_steps: - rollback_rules = [ - RollbackRule(target_step=r["target"], condition=r["condition"]) for r in raw.get("rollback", []) - ] - raw_tools = raw.get("tools") if raw_tools is not None: tools = IncludeExcludeConfig( @@ -227,7 +222,6 @@ def _parse_steps(raw_steps: list[dict]) -> list[StepSpec]: step_type=raw.get("type", "normal"), sub_pipeline_name=raw.get("sub_pipeline"), tools=tools, - rollback_rules=rollback_rules, auto_advance=raw.get("auto_advance", True), max_agent_turns=raw.get("max_agent_turns", 50), context_fields=raw.get("context_fields", []), diff --git a/src/iac_code/pipeline/engine/pipeline_runner.py b/src/iac_code/pipeline/engine/pipeline_runner.py index 8b73bef5..aa5cf407 100644 --- a/src/iac_code/pipeline/engine/pipeline_runner.py +++ b/src/iac_code/pipeline/engine/pipeline_runner.py @@ -3120,7 +3120,7 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> target, reason = step_result.rollback_request current_attempt_id = attempt.get("attempt_id") try: - self.state_machine.rollback(target, reason, allow_completed_non_future=True) + self.state_machine.rollback(target, reason) except ValueError as exc: valid_targets = self.state_machine.completed_non_future_rollback_targets() error_message = f"Invalid rollback target {target!r}. Valid targets: {valid_targets}. ({exc})" diff --git a/src/iac_code/pipeline/engine/state_machine.py b/src/iac_code/pipeline/engine/state_machine.py index efb65c65..ccfae86b 100644 --- a/src/iac_code/pipeline/engine/state_machine.py +++ b/src/iac_code/pipeline/engine/state_machine.py @@ -14,8 +14,8 @@ class StateMachine: """Generic pipeline state machine. Steps are ordered linearly. Each step's config defines its forward - target and rollback rules. The state machine tracks the current - position, step statuses, and rollback count. + target. The state machine tracks the current position, step statuses, + and rollback count. """ def __init__(self, steps: list[StepSpec], max_rollbacks: int = 3, max_interrupt_rollbacks: int = 10) -> None: @@ -63,9 +63,9 @@ def advance(self) -> StepSpec | None: self._step_statuses[step.forward] = StepStatus.RUNNING return self.current_step - def rollback(self, target_step_id: str, reason: str, *, allow_completed_non_future: bool = False) -> StepSpec: + def rollback(self, target_step_id: str, reason: str) -> StepSpec: """Roll back to target step, marking intermediates as stale.""" - if not self.can_rollback_to(target_step_id, allow_completed_non_future=allow_completed_non_future): + if not self.can_rollback_to(target_step_id): raise ValueError(f"Cannot rollback from {self.current_step.step_id} to {target_step_id}") if self._rollback_count >= self._max_rollbacks: raise ValueError(f"Max rollbacks ({self._max_rollbacks}) exceeded") @@ -81,14 +81,8 @@ def rollback(self, target_step_id: str, reason: str, *, allow_completed_non_futu self._step_statuses[target_step_id] = StepStatus.RUNNING return self.current_step - def can_rollback_to(self, target_step_id: str, *, allow_completed_non_future: bool = False) -> bool: - if allow_completed_non_future: - return target_step_id in self.completed_non_future_rollback_targets() - step = self.current_step - return any(r.target_step == target_step_id for r in step.rollback_rules) - - def get_rollback_options(self) -> list: - return self.current_step.rollback_rules + def can_rollback_to(self, target_step_id: str) -> bool: + return target_step_id in self.completed_non_future_rollback_targets() def completed_non_future_rollback_targets(self) -> list[str]: """Return completed rollback targets at or before the current position.""" @@ -114,7 +108,7 @@ def can_interrupt_rollback_to(self, target_step_id: str) -> tuple[bool, str | No return True, None def interrupt_rollback(self, target_step_id: str, reason: str) -> StepSpec: - """User-interrupt rollback. Not constrained by rollback_rules but has its own limit.""" + """User-interrupt rollback to current or prior steps with its own limit.""" ok, error = self.can_interrupt_rollback_to(target_step_id) if not ok: if error == "unknown_step": diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 6804faec..3ec24fb6 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -621,13 +621,10 @@ def _build_step_tools( step_id=step.step_id, conclusion_field=step.conclusion_field, forward=step.forward, - rollback_rules=step.rollback_rules, auto_advance=step.auto_advance, max_agent_turns=step.max_agent_turns, conclusion_schema=step.conclusion_schema, - rollback_targets=rollback_targets - if rollback_targets is not None - else [r.target_step for r in step.rollback_rules], + rollback_targets=rollback_targets if rollback_targets is not None else [], max_conclusion_retries=step.max_conclusion_retries, rollback_count=rollback_count, max_rollbacks=max_rollbacks, diff --git a/src/iac_code/pipeline/engine/step_spec.py b/src/iac_code/pipeline/engine/step_spec.py index c2b62b0d..74902987 100644 --- a/src/iac_code/pipeline/engine/step_spec.py +++ b/src/iac_code/pipeline/engine/step_spec.py @@ -7,7 +7,6 @@ from dataclasses import dataclass, field from iac_code.pipeline.engine.context import PipelineContext -from iac_code.pipeline.engine.types import RollbackRule @dataclass @@ -55,7 +54,6 @@ class StepSpec: step_type: str = "normal" sub_pipeline_name: str | None = None tools: IncludeExcludeConfig | None = None - rollback_rules: list[RollbackRule] = field(default_factory=list) auto_advance: bool = True max_agent_turns: int = 50 context_fields: list[str] = field(default_factory=list) diff --git a/src/iac_code/pipeline/engine/sub_pipeline_executor.py b/src/iac_code/pipeline/engine/sub_pipeline_executor.py index 48f78353..b7173e36 100644 --- a/src/iac_code/pipeline/engine/sub_pipeline_executor.py +++ b/src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -263,7 +263,7 @@ async def execute( if step_result.rollback_request: target, reason = step_result.rollback_request try: - state_machine.rollback(target, reason, allow_completed_non_future=True) + state_machine.rollback(target, reason) conclusions = self._conclusions_before_step(sub_spec, target, conclusions) self._mark_rolled_back_fields_stale(sub_context, sub_spec, target) except ValueError as e: @@ -693,7 +693,7 @@ def sub_step_attrs_for_current(step, step_index: int) -> dict[str, Any]: if step_result.rollback_request: target, reason = step_result.rollback_request try: - state_machine.rollback(target, reason, allow_completed_non_future=True) + state_machine.rollback(target, reason) conclusions = self._conclusions_before_step(sub_spec, target, conclusions) self._observability.sub_step_completed( duration_ms=self._observability.duration_ms(sub_step_started_at), diff --git a/src/iac_code/pipeline/engine/types.py b/src/iac_code/pipeline/engine/types.py index 99b54a31..aa3f7d31 100644 --- a/src/iac_code/pipeline/engine/types.py +++ b/src/iac_code/pipeline/engine/types.py @@ -14,15 +14,6 @@ class StepStatus(str, Enum): FAILED = "failed" -@dataclass -class RollbackRule: - """Configurable rollback rule for a step.""" - - target_step: str - condition: str - invalidates: list[str] = field(default_factory=list) - - @dataclass class StepConfig: """Static configuration for a pipeline step.""" @@ -30,7 +21,6 @@ class StepConfig: step_id: str conclusion_field: str forward: str | None - rollback_rules: list[RollbackRule] = field(default_factory=list) auto_advance: bool = True max_agent_turns: int = 50 conclusion_schema: dict | None = None diff --git a/src/iac_code/pipeline/selling/pipeline.yaml b/src/iac_code/pipeline/selling/pipeline.yaml index 821f67d5..e3ac39fa 100644 --- a/src/iac_code/pipeline/selling/pipeline.yaml +++ b/src/iac_code/pipeline/selling/pipeline.yaml @@ -86,9 +86,6 @@ sub_pipelines: tools: include: [-] exclude: [] - rollback: - - target: template_generating - condition: template_issue - id: cost_estimating description: "评估模板的资源成本" @@ -186,9 +183,6 @@ steps: tools: include: [read_memory, read_file, list_files, glob, grep, web_fetch, aliyun_doc_search] exclude: [] - rollback: - - target: intent_parsing - condition: intent_unclear - id: evaluate_candidates description: "并行生成各候选方案的 IaC 模板并评估成本" @@ -241,9 +235,6 @@ steps: selected_candidate_index: type: integer description: 用户最终选择的候选方案在 evaluated_candidates 中的 0 基下标;首次展示方案时可省略 - rollback: - - target: architecture_planning - condition: want_different_plans - id: deploying description: "执行用户选定方案的部署" @@ -257,8 +248,3 @@ steps: tools: include: [] exclude: [write_memory] - rollback: - - target: confirm_and_select - condition: invalid_selection - - target: architecture_planning - condition: need_different_architecture diff --git a/tests/pipeline/engine/test_loader.py b/tests/pipeline/engine/test_loader.py index 9fd05d28..2f675dd2 100644 --- a/tests/pipeline/engine/test_loader.py +++ b/tests/pipeline/engine/test_loader.py @@ -48,6 +48,32 @@ def test_loads_basic_pipeline(self, tmp_path): assert loaded.steps[1].context_fields == ["intent"] assert loaded.max_rollbacks == 2 + def test_ignores_legacy_step_rollback_section(self, tmp_path): + yaml_content = dedent("""\ + name: test + context_dependencies: + intent: [] + architecture: [intent] + max_rollbacks: 2 + steps: + - id: step_a + conclusion_field: intent + forward: step_b + prompt: prompts/step_a.md + - id: step_b + conclusion_field: architecture + forward: null + prompt: prompts/step_b.md + rollback: + - target: step_a + condition: revise_intent + """) + _write_pipeline(tmp_path, yaml_content, {"step_a.md": "Do A", "step_b.md": "Do B"}) + + loaded = load_pipeline_dir(tmp_path) + + assert not hasattr(loaded.steps[1], "rollback_rules") + def test_selling_iac_aliyun_skill_reference_file_uses_bundled_root_fallback(self, tmp_path): _write_pipeline(tmp_path, MINIMAL_YAML, {"step_a.md": "Do A", "step_b.md": "Do B with {intent}"}) skill_dir = tmp_path / "skills" / "iac-aliyun-cost" diff --git a/tests/pipeline/engine/test_pipeline_observability.py b/tests/pipeline/engine/test_pipeline_observability.py index 4ceb2885..e8edb61b 100644 --- a/tests/pipeline/engine/test_pipeline_observability.py +++ b/tests/pipeline/engine/test_pipeline_observability.py @@ -12,7 +12,7 @@ from iac_code.pipeline.engine.pipeline_runner import PipelineRunner from iac_code.pipeline.engine.session import RestoreResult from iac_code.pipeline.engine.step_spec import LoadedPipeline, StepSpec, SubPipelineSpec -from iac_code.pipeline.engine.types import RollbackRule, StepResult, StepStatus +from iac_code.pipeline.engine.types import StepResult, StepStatus from iac_code.services.telemetry.config import ContentCaptureMode from iac_code.services.telemetry.names import Events, GenAiAttr, GenAiOperationName, GenAiSpanKind, Metrics, Spans @@ -1491,7 +1491,6 @@ def test_restore_from_sidecar_emits_sidecar_failed_for_real_restore_failure(runn async def test_runner_emits_parent_rollback_telemetry(runner): runner._observability.rollback = MagicMock() runner.state_machine.advance() - runner.state_machine.current_step.rollback_rules.append(RollbackRule(target_step="a", condition="revise")) async def fake_execute(step, context, session_id, user_message=None, **kwargs): conclusion = {"value": step.step_id} @@ -1529,7 +1528,6 @@ async def test_runner_distinguishes_step_attempts_after_parent_rollback(runner): runner._observability.step_completed = MagicMock() runner._observability.funnel_step = MagicMock() runner._observability.rollback = MagicMock() - runner.state_machine._steps["b"].rollback_rules.append(RollbackRule(target_step="a", condition="revise")) seen: dict[str, int] = {"a": 0, "b": 0} async def fake_execute(step, context, session_id, user_message=None, **kwargs): diff --git a/tests/pipeline/engine/test_pipeline_runner.py b/tests/pipeline/engine/test_pipeline_runner.py index 9fa33bd8..f6d7c3d3 100644 --- a/tests/pipeline/engine/test_pipeline_runner.py +++ b/tests/pipeline/engine/test_pipeline_runner.py @@ -208,9 +208,6 @@ def _build_parallel_runner(tmp_path, *, storage=None): forward: null prompt: prompts/cost.md context_fields: [template] - rollback: - - target: template_gen - condition: needs_template_rework steps: - id: arch conclusion_field: architecture diff --git a/tests/pipeline/engine/test_state_machine.py b/tests/pipeline/engine/test_state_machine.py index 6a19c214..b6cfc1dc 100644 --- a/tests/pipeline/engine/test_state_machine.py +++ b/tests/pipeline/engine/test_state_machine.py @@ -2,30 +2,15 @@ from iac_code.pipeline.engine.state_machine import StateMachine from iac_code.pipeline.engine.step_spec import StepSpec -from iac_code.pipeline.engine.types import RollbackRule, StepStatus +from iac_code.pipeline.engine.types import StepStatus def _make_three_steps(): """A → B → C pipeline.""" return [ StepSpec(step_id="a", conclusion_field="a", forward="b", prompt_file="a.md"), - StepSpec( - step_id="b", - conclusion_field="b", - forward="c", - prompt_file="b.md", - rollback_rules=[RollbackRule(target_step="a", condition="fix")], - ), - StepSpec( - step_id="c", - conclusion_field="c", - forward=None, - prompt_file="c.md", - rollback_rules=[ - RollbackRule(target_step="a", condition="restart"), - RollbackRule(target_step="b", condition="revise"), - ], - ), + StepSpec(step_id="b", conclusion_field="b", forward="c", prompt_file="b.md"), + StepSpec(step_id="c", conclusion_field="c", forward=None, prompt_file="c.md"), ] @@ -66,11 +51,11 @@ def test_rollback_to_allowed_target(self): assert sm._step_statuses["b"] == StepStatus.STALE assert sm._step_statuses["c"] == StepStatus.STALE - def test_rollback_to_disallowed_target_raises(self): + def test_rollback_to_future_target_raises(self): sm = StateMachine(_make_three_steps()) sm.advance() # a → b with pytest.raises(ValueError, match="Cannot rollback"): - sm.rollback("c", "invalid") # b can only roll back to a + sm.rollback("c", "invalid") def test_max_rollbacks_exceeded(self): sm = StateMachine(_make_three_steps(), max_rollbacks=1) @@ -98,14 +83,11 @@ def test_can_rollback_to(self): assert sm.can_rollback_to("b") assert not sm.can_rollback_to("nonexistent") - def test_get_rollback_options(self): + def test_completed_non_future_rollback_targets(self): sm = StateMachine(_make_three_steps()) sm.advance() sm.advance() - options = sm.get_rollback_options() - assert len(options) == 2 - targets = {r.target_step for r in options} - assert targets == {"a", "b"} + assert sm.completed_non_future_rollback_targets() == ["a", "b"] def test_completed_non_future_rollback_targets_ignore_static_rules(self): steps = [ @@ -118,7 +100,7 @@ def test_completed_non_future_rollback_targets_ignore_static_rules(self): sm.advance() # b -> c assert sm.completed_non_future_rollback_targets() == ["a", "b"] - step = sm.rollback("b", "revise completed step", allow_completed_non_future=True) + step = sm.rollback("b", "revise completed step") assert step.step_id == "b" @@ -133,7 +115,7 @@ def test_completed_non_future_rollback_rejects_future_and_uncompleted_targets(se assert sm.completed_non_future_rollback_targets() == ["a"] with pytest.raises(ValueError, match="Cannot rollback"): - sm.rollback("c", "future", allow_completed_non_future=True) + sm.rollback("c", "future") class TestInterruptRollback: @@ -183,10 +165,9 @@ def test_interrupt_rollback_to_current_step(self): assert step.step_id == "b" assert sm._step_statuses["b"] == StepStatus.RUNNING - def test_interrupt_rollback_ignores_rollback_rules(self): - """interrupt_rollback should work even without rollback_rules.""" + def test_interrupt_rollback_accepts_current_or_prior_step(self): sm = StateMachine(_make_three_steps()) - sm.advance() # a → b (b can only roll back to a via rules) + sm.advance() # a → b sm.advance() # b → c step = sm.interrupt_rollback("b", "no rule needed") assert step.step_id == "b" diff --git a/tests/pipeline/engine/test_step_executor.py b/tests/pipeline/engine/test_step_executor.py index f6f833a7..90db7087 100644 --- a/tests/pipeline/engine/test_step_executor.py +++ b/tests/pipeline/engine/test_step_executor.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, call, patch import pytest @@ -8,7 +9,7 @@ from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.step_executor import StepExecutor from iac_code.pipeline.engine.step_spec import IncludeExcludeConfig, LoadedPipeline, StepSpec -from iac_code.pipeline.engine.types import RollbackRule, StepResult, StepStatus +from iac_code.pipeline.engine.types import StepResult, StepStatus from iac_code.tools.base import ToolContext, ToolRegistry from iac_code.types.stream_events import ( AskUserQuestionEvent, @@ -1575,7 +1576,6 @@ def test_passes_rollback_targets_to_tool(self, tmp_path): conclusion_field="intent", forward="arch", prompt_file="prompts/intent_parsing.md", - rollback_rules=[RollbackRule(target_step="prev_step", condition="bad")], ) executor = StepExecutor( provider_manager=MagicMock(), @@ -1584,18 +1584,40 @@ def test_passes_rollback_targets_to_tool(self, tmp_path): pipeline_dir=tmp_path, ) ctx = PipelineContext(SIMPLE_DEPS) - tool_reg = executor._build_step_tools(step, ctx) + tool_reg = executor._build_step_tools(step, ctx, rollback_targets=["prev_step"]) complete_tool = tool_reg.get("complete_step") schema = complete_tool.input_schema assert schema["properties"]["rollback_request"]["properties"]["target_step"]["enum"] == ["prev_step"] + def test_does_not_fallback_to_static_rollback_rules(self, tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "intent_parsing.md").write_text("Parse.", encoding="utf-8") + step = StepSpec( + step_id="intent_parsing", + conclusion_field="intent", + forward="arch", + prompt_file="prompts/intent_parsing.md", + ) + setattr(step, "rollback_rules", [SimpleNamespace(target_step="legacy_prev", condition="bad")]) + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=_make_pipeline(), + pipeline_dir=tmp_path, + ) + ctx = PipelineContext(SIMPLE_DEPS) + tool_reg = executor._build_step_tools(step, ctx) + complete_tool = tool_reg.get("complete_step") + + assert "rollback_request" not in complete_tool.input_schema["properties"] + class TestSchemaIntegration: """Integration test: schema flows from StepSpec through StepExecutor to CompleteStepTool.""" def test_conclusion_schema_and_rollback_targets_propagate(self, tmp_path): """Verify that conclusion_schema from StepSpec reaches the tool's input_schema, - and rollback_rules produce correct enum constraint on rollback_request.target_step.""" + and explicit rollback targets produce correct enum constraint on rollback_request.target_step.""" (tmp_path / "prompts").mkdir(exist_ok=True) (tmp_path / "prompts" / "intent_parsing.md").write_text("Do it.", encoding="utf-8") step = StepSpec( @@ -1611,10 +1633,6 @@ def test_conclusion_schema_and_rollback_targets_propagate(self, tmp_path): "confidence": {"type": "string", "enum": ["high", "medium", "low"]}, }, }, - rollback_rules=[ - RollbackRule(target_step="prev_step", condition="bad_input"), - RollbackRule(target_step="other_step", condition="other_issue"), - ], max_conclusion_retries=3, ) executor = StepExecutor( @@ -1624,7 +1642,7 @@ def test_conclusion_schema_and_rollback_targets_propagate(self, tmp_path): pipeline_dir=tmp_path, ) ctx = PipelineContext(SIMPLE_DEPS) - tool_reg = executor._build_step_tools(step, ctx) + tool_reg = executor._build_step_tools(step, ctx, rollback_targets=["prev_step", "other_step"]) tool = tool_reg.get("complete_step") schema = tool.input_schema @@ -1665,7 +1683,7 @@ def test_no_schema_falls_back_to_generic(self, tmp_path): assert conclusion["type"] == "object" assert "description" in conclusion assert "properties" not in conclusion - # No rollback when no rollback_rules + # No rollback unless the runner passes dynamic rollback targets. assert "rollback_request" not in schema["properties"] diff --git a/tests/pipeline/engine/test_step_spec.py b/tests/pipeline/engine/test_step_spec.py index fe02d8d3..a7ea4663 100644 --- a/tests/pipeline/engine/test_step_spec.py +++ b/tests/pipeline/engine/test_step_spec.py @@ -6,7 +6,6 @@ SubPipelineSpec, render_prompt, ) -from iac_code.pipeline.engine.types import RollbackRule class TestIncludeExcludeConfig: @@ -93,14 +92,13 @@ def test_create_full(self): forward="cost_estimating", prompt_file="prompts/reviewing.md", skill="iac-aliyun-review", - rollback_rules=[RollbackRule(target_step="template_generating", condition="template_issue")], context_fields=["template"], enabled_when="cost_estimation", hooks_file="hooks/deploying.py", ) assert spec.skill == "iac-aliyun-review" assert spec.enabled_when == "cost_estimation" - assert len(spec.rollback_rules) == 1 + assert spec.context_fields == ["template"] def test_parallel_sub_pipeline_step(self): spec = StepSpec( diff --git a/tests/pipeline/engine/test_sub_pipeline_executor.py b/tests/pipeline/engine/test_sub_pipeline_executor.py index 19a6b90f..8f0eccf5 100644 --- a/tests/pipeline/engine/test_sub_pipeline_executor.py +++ b/tests/pipeline/engine/test_sub_pipeline_executor.py @@ -11,7 +11,7 @@ from iac_code.pipeline.engine.state_machine import StateMachine from iac_code.pipeline.engine.step_spec import LoadedPipeline, StepSpec, SubPipelineSpec from iac_code.pipeline.engine.sub_pipeline_executor import SubPipelineExecutor, SubPipelineResult -from iac_code.pipeline.engine.types import RollbackRule, StepResult, StepStatus +from iac_code.pipeline.engine.types import StepResult, StepStatus from iac_code.tools.base import ToolRegistry from iac_code.types.stream_events import ToolResultEvent, ToolUseEndEvent, ToolUseStartEvent @@ -450,7 +450,6 @@ async def test_rollback_persistence_drops_stale_target_and_downstream_conclusion forward=None, prompt_file="prompts/cost.md", context_fields=["template"], - rollback_rules=[RollbackRule(target_step="template_generating", condition="needs_template_rework")], ), ], max_rollbacks=2, diff --git a/tests/pipeline/engine/test_types.py b/tests/pipeline/engine/test_types.py index 516aa869..a9ff4fa9 100644 --- a/tests/pipeline/engine/test_types.py +++ b/tests/pipeline/engine/test_types.py @@ -1,4 +1,4 @@ -from iac_code.pipeline.engine.types import RollbackRule, StepConfig, StepResult, StepStatus +from iac_code.pipeline.engine.types import StepConfig, StepResult, StepStatus class TestStepStatus: @@ -13,22 +13,6 @@ def test_is_str_enum(self): assert isinstance(StepStatus.PENDING, str) -class TestRollbackRule: - def test_basic_construction(self): - rule = RollbackRule(target_step="intent_parsing", condition="user_request") - assert rule.target_step == "intent_parsing" - assert rule.condition == "user_request" - assert rule.invalidates == [] - - def test_with_invalidates(self): - rule = RollbackRule( - target_step="architecture_planning", - condition="cost_too_high", - invalidates=["specs", "template"], - ) - assert rule.invalidates == ["specs", "template"] - - class TestStepConfig: def test_defaults(self): config = StepConfig( @@ -38,22 +22,21 @@ def test_defaults(self): ) assert config.auto_advance is True assert config.max_agent_turns == 50 - assert config.rollback_rules == [] + assert config.rollback_targets == [] def test_custom_values(self): - rules = [RollbackRule(target_step="prev", condition="wrong")] config = StepConfig( step_id="my_step", conclusion_field="my_field", forward=None, - rollback_rules=rules, + rollback_targets=["prev"], auto_advance=False, max_agent_turns=20, ) assert config.forward is None assert config.auto_advance is False assert config.max_agent_turns == 20 - assert len(config.rollback_rules) == 1 + assert config.rollback_targets == ["prev"] class TestStepResult: diff --git a/tests/pipeline/selling/test_terminal_ui_contract.py b/tests/pipeline/selling/test_terminal_ui_contract.py index e6fad583..51ae5823 100644 --- a/tests/pipeline/selling/test_terminal_ui_contract.py +++ b/tests/pipeline/selling/test_terminal_ui_contract.py @@ -24,12 +24,10 @@ def test_confirm_prompt_tells_model_to_output_candidate_index(): assert "`options[].candidate_index`" in prompt -def test_deploying_can_rollback_to_confirm_and_select_for_invalid_selection(): +def test_selling_steps_do_not_expose_static_rollback_rules(): loaded = load_pipeline_dir(_selling_pipeline_dir()) - deploying = next(step for step in loaded.steps if step.step_id == "deploying") - rollback_pairs = {(rule.target_step, rule.condition) for rule in deploying.rollback_rules} - assert ("confirm_and_select", "invalid_selection") in rollback_pairs + assert all(not hasattr(step, "rollback_rules") for step in loaded.steps) def test_deploying_pauses_when_interrupt_judge_fails(): From 891f352c68fd029663ad65836971fd2cede211d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 22 Jun 2026 17:53:08 +0800 Subject: [PATCH 11/59] fix: resolve pipeline skill reference paths Add a pipeline-only relative read root so step agents can read skill reference files by relative path without changing normal REPL trusted-read behavior. Preserve ToolContext compatibility and cover the pipeline/non-pipeline boundaries with regression tests. --- src/iac_code/agent/agent_loop.py | 41 +++++++++- src/iac_code/pipeline/engine/step_executor.py | 25 ++++++ src/iac_code/tools/base.py | 3 + src/iac_code/tools/read_file.py | 30 ++++++- src/iac_code/tools/tool_executor.py | 3 + tests/agent/test_permission_scenarios.py | 78 +++++++++++++++++++ .../skills/test_template_generating_skill.py | 24 ++++++ tests/tools/test_base.py | 7 ++ tests/tools/test_read_file.py | 58 ++++++++++++++ tests/tools/test_tool_executor.py | 36 +++++++++ 10 files changed, 299 insertions(+), 6 deletions(-) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index b8eee7ec..531caad0 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,8 @@ 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, ) -> None: self._provider_manager = provider_manager self.system_prompt = system_prompt @@ -141,6 +163,8 @@ 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._auto_trigger_skills = auto_trigger_skills or [] self._auto_loaded_skills: set[str] = set() self._current_git_branch: str | None = None @@ -913,7 +937,11 @@ 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), + ) allowed_requests: list[ToolCallRequest] = [] denied_results: list[tuple[ToolCallRequest, ToolResult]] = [] @@ -932,7 +960,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}) diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 3ec24fb6..d6685dc9 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -346,6 +346,7 @@ def build_agent_loop_context( from iac_code.agent.agent_loop import AgentLoop agent_session_id = transcript_id or session_id + step_skill_roots = self._resolve_step_skill_roots(step) agent_loop = AgentLoop( provider_manager=self._provider_manager, system_prompt=system_prompt, @@ -358,6 +359,8 @@ def build_agent_loop_context( pause_event=self._pause_event, permission_context_getter=self._permission_context_getter, auto_trigger_skills=self._resolve_auto_trigger_skills(step), + tool_context_trusted_read_directories=step_skill_roots, + tool_context_relative_read_directories=step_skill_roots, ) return StepAgentLoopContext( agent_loop=agent_loop, @@ -586,6 +589,28 @@ def _resolve_skill_prompt(self, skill_name: str) -> str | None: return None + def _resolve_step_skill_roots(self, step: StepSpec) -> list[str]: + if not step.skill: + return [] + root = self._resolve_skill_root(step.skill) + return [root] if root else [] + + def _resolve_skill_root(self, skill_name: str) -> str: + root = self._pipeline.skill_roots.get(skill_name, "") + if root: + return root + + try: + from iac_code.skills.bundled import get_bundled_skills + + for skill_def in get_bundled_skills(): + if skill_def.name == skill_name: + return skill_def.skill_root + except ImportError: + pass + + return "" + @staticmethod def _with_skill_base_directory(content: str, skill_root: str) -> str: if not skill_root: diff --git a/src/iac_code/tools/base.py b/src/iac_code/tools/base.py index 794afd0b..f7108cb1 100644 --- a/src/iac_code/tools/base.py +++ b/src/iac_code/tools/base.py @@ -21,11 +21,14 @@ class ToolContext: cwd: str = field(default_factory=os.getcwd) event_queue: asyncio.Queue | None = None + additional_directories: list[str] = field(default_factory=list) + trusted_read_directories: list[str] = field(default_factory=list) # U-I14: tool_use_id of the current tool invocation; lets tools tag emitted # events so multiple parallel calls of the same tool can be distinguished # downstream (e.g. in the renderer's per-tab accumulator). Populated by the # ToolExecutor on each call. tool_use_id: str | None = None + relative_read_directories: list[str] = field(default_factory=list) @dataclass diff --git a/src/iac_code/tools/read_file.py b/src/iac_code/tools/read_file.py index 64a42ed6..57c682e1 100644 --- a/src/iac_code/tools/read_file.py +++ b/src/iac_code/tools/read_file.py @@ -15,8 +15,28 @@ MAX_READ_LINES = 50_000 -def _resolve_input_path(path: str, cwd: str) -> str: - return resolve_candidate(path, cwd) +def _path_is_under(path: str, root: str) -> bool: + try: + return os.path.commonpath([os.path.realpath(path), os.path.realpath(root)]) == os.path.realpath(root) + except ValueError: + return False + + +def _resolve_input_path( + path: str, + cwd: str, + *, + relative_read_directories: list[str] | None = None, +) -> str: + primary = resolve_candidate(path, cwd) + if os.path.isabs(os.path.expanduser(path)) or os.path.exists(primary): + return primary + + for root in relative_read_directories or []: + candidate = resolve_candidate(path, root) + if _path_is_under(candidate, root) and os.path.exists(candidate): + return candidate + return primary class ReadFileTool(Tool): @@ -85,7 +105,11 @@ async def check_permissions(self, input: dict, context=None) -> PermissionResult return decision.to_permission_result() async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: - path = _resolve_input_path(tool_input["path"], context.cwd) + path = _resolve_input_path( + tool_input["path"], + context.cwd, + relative_read_directories=context.relative_read_directories, + ) start_line = tool_input.get("start_line") end_line = tool_input.get("end_line") diff --git a/src/iac_code/tools/tool_executor.py b/src/iac_code/tools/tool_executor.py index 0708f3ce..12f8848a 100644 --- a/src/iac_code/tools/tool_executor.py +++ b/src/iac_code/tools/tool_executor.py @@ -69,6 +69,9 @@ async def _validate_and_execute(self, call: ToolCallRequest, context: ToolContex context = ToolContext( cwd=context.cwd, event_queue=call.event_queue if call.event_queue is not None else context.event_queue, + additional_directories=list(context.additional_directories), + trusted_read_directories=list(context.trusted_read_directories), + relative_read_directories=list(context.relative_read_directories), tool_use_id=call.id, ) diff --git a/tests/agent/test_permission_scenarios.py b/tests/agent/test_permission_scenarios.py index c430eb73..1388a798 100644 --- a/tests/agent/test_permission_scenarios.py +++ b/tests/agent/test_permission_scenarios.py @@ -70,6 +70,21 @@ def _bash_turn(tool_use_id: str, command: str, *, text: str = "") -> list: return events +def _read_file_turn(tool_use_id: str, path: str, *, text: str = "") -> list: + """Build a fake LLM turn that calls read_file with the given path.""" + events = [MessageStartEvent(message_id=f"msg-{tool_use_id}")] + if text: + events.append(TextDeltaEvent(text=text)) + events.extend( + [ + ToolUseStartEvent(tool_use_id=tool_use_id, name="read_file"), + ToolUseEndEvent(tool_use_id=tool_use_id, name="read_file", input={"path": path}), + MessageEndEvent(stop_reason="tool_use", usage=Usage()), + ] + ) + return events + + def _text_turn(text: str) -> list: """Build a fake LLM turn that just responds with text (no tool calls).""" return [ @@ -126,6 +141,69 @@ async def test_readonly_command_auto_allowed(self): results = _tool_results(events) assert any(not r.is_error for r in results) + @pytest.mark.asyncio + async def test_loop_trusted_read_roots_apply_to_read_file_permissions(self, tmp_path): + """Agent loop skill read roots should be available to permission checks and tool execution.""" + project = tmp_path / "project" + skill_root = tmp_path / "skill" + project.mkdir() + skill_root.mkdir() + reference = skill_root / "template-parameters.md" + reference.write_text("Skill reference content", encoding="utf-8") + + provider = FakeProvider([_read_file_turn("t1", str(reference)), _text_turn("done")]) + registry = ToolRegistry() + registry.register_default_tools() + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(project), + max_turns=2, + permission_context=ToolPermissionContext(cwd=str(project)), + tool_context_trusted_read_directories=[str(skill_root)], + ) + + events = await _collect_events(loop, "read skill reference") + + assert not _has_permission_request(events) + results = _tool_results(events) + assert any("Skill reference content" in result.result for result in results) + + @pytest.mark.asyncio + async def test_session_trusted_read_roots_do_not_change_relative_read_lookup(self, tmp_path): + """Non-pipeline trusted read roots should not make read_file resolve relative paths from them.""" + project = tmp_path / "project" + session_root = tmp_path / "session-artifacts" + project.mkdir() + reference = session_root / "references" / "template-parameters.md" + reference.parent.mkdir(parents=True) + reference.write_text("Session reference content", encoding="utf-8") + + provider = FakeProvider([_read_file_turn("t1", "references/template-parameters.md"), _text_turn("done")]) + registry = ToolRegistry() + registry.register_default_tools() + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(project), + max_turns=2, + permission_context=ToolPermissionContext( + cwd=str(project), + trusted_read_directories=[str(session_root)], + ), + ) + + events = await _collect_events(loop, "read session reference") + + assert not _has_permission_request(events) + results = _tool_results(events) + assert any(result.is_error for result in results) + expected_error = f"File not found: {project / 'references' / 'template-parameters.md'}" + assert any(expected_error in result.result for result in results) + assert not any("Session reference content" in result.result for result in results) + @pytest.mark.asyncio async def test_curl_requires_permission(self): """curl should prompt for permission.""" diff --git a/tests/pipeline/selling/skills/test_template_generating_skill.py b/tests/pipeline/selling/skills/test_template_generating_skill.py index ae94c6dd..b1e5ec2b 100644 --- a/tests/pipeline/selling/skills/test_template_generating_skill.py +++ b/tests/pipeline/selling/skills/test_template_generating_skill.py @@ -139,6 +139,30 @@ def test_full_prompt_includes_skill_base_directory(self, tmp_path): assert f"Base directory for this skill: {SKILL_DIR}" in prompt + def test_agent_loop_trusts_skill_base_directory_for_tools(self, tmp_path): + from iac_code.pipeline.engine.context import PipelineContext + from iac_code.pipeline.engine.loader import load_pipeline_dir + from iac_code.pipeline.engine.step_executor import StepExecutor + from iac_code.tools.base import ToolRegistry + + pipeline_dir = SKILL_DIR.parents[1] + loaded = load_pipeline_dir(pipeline_dir) + step = next(s for s in loaded.sub_pipelines["evaluate_candidate"].steps if s.step_id == "template_generating") + context = PipelineContext({"candidate": []}) + context.set_conclusion("candidate", {"output_path": "templates/example.yml"}) + + agent_context = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=loaded, + pipeline_dir=pipeline_dir, + cwd=str(tmp_path), + ).build_agent_loop_context(step, context, "session-1") + + assert agent_context.agent_loop is not None + assert str(SKILL_DIR) in agent_context.agent_loop._tool_context_trusted_read_directories + assert str(SKILL_DIR) in agent_context.agent_loop._tool_context_relative_read_directories + class TestEvalsJson: def test_evals_file_exists(self): diff --git a/tests/tools/test_base.py b/tests/tools/test_base.py index 8f8f6c2c..3018336a 100644 --- a/tests/tools/test_base.py +++ b/tests/tools/test_base.py @@ -19,6 +19,13 @@ def test_custom_cwd(self): ctx = ToolContext(cwd="/tmp") assert ctx.cwd == "/tmp" + def test_tool_use_id_positional_compatibility(self): + """Adding fields must not shift the existing tool_use_id positional slot.""" + ctx = ToolContext("/tmp", None, [], [], "toolu-1") + + assert ctx.tool_use_id == "toolu-1" + assert ctx.relative_read_directories == [] + class TestToolResult: """Tests for ToolResult.""" diff --git a/tests/tools/test_read_file.py b/tests/tools/test_read_file.py index 1f0ffa08..e14766b7 100644 --- a/tests/tools/test_read_file.py +++ b/tests/tools/test_read_file.py @@ -122,6 +122,64 @@ async def test_read_file_in_subdirectory(self, tmp_path, read_file_tool): assert result.is_error is False assert "Nested content" in result.content + @pytest.mark.asyncio + async def test_relative_path_falls_back_to_relative_read_directory(self, tmp_path, read_file_tool): + """Skill reference links should resolve from explicit relative read roots when absent from cwd.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + skill_root = tmp_path / "skill" + reference = skill_root / "references" / "template-parameters.md" + reference.parent.mkdir(parents=True) + reference.write_text("Parameter reference content", encoding="utf-8") + + context = ToolContext(cwd=str(workspace), relative_read_directories=[str(skill_root)]) + result = await read_file_tool.execute( + tool_input={"path": "references/template-parameters.md"}, + context=context, + ) + + assert result.is_error is False + assert "Parameter reference content" in result.content + assert f"File: {reference}" in result.content + + @pytest.mark.asyncio + async def test_relative_path_does_not_fall_back_to_trusted_read_directory(self, tmp_path, read_file_tool): + """Trusted read roots should allow explicit reads without changing relative lookup semantics.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + trusted_root = tmp_path / "trusted" + reference = trusted_root / "references" / "template-parameters.md" + reference.parent.mkdir(parents=True) + reference.write_text("Trusted reference content", encoding="utf-8") + + context = ToolContext(cwd=str(workspace), trusted_read_directories=[str(trusted_root)]) + result = await read_file_tool.execute( + tool_input={"path": "references/template-parameters.md"}, + context=context, + ) + + assert result.is_error is True + assert f"File not found: {workspace / 'references' / 'template-parameters.md'}" in result.content + + @pytest.mark.asyncio + async def test_relative_path_does_not_fall_back_to_additional_directory(self, tmp_path, read_file_tool): + """Additional directories should not change relative path lookup semantics.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + shared_root = tmp_path / "shared" + reference = shared_root / "references" / "template-parameters.md" + reference.parent.mkdir(parents=True) + reference.write_text("Shared reference content", encoding="utf-8") + + context = ToolContext(cwd=str(workspace), additional_directories=[str(shared_root)]) + result = await read_file_tool.execute( + tool_input={"path": "references/template-parameters.md"}, + context=context, + ) + + assert result.is_error is True + assert f"File not found: {workspace / 'references' / 'template-parameters.md'}" in result.content + @pytest.mark.asyncio async def test_read_file_start_line_only(self, tmp_path, read_file_tool): """Test reading with only start_line specified.""" diff --git a/tests/tools/test_tool_executor.py b/tests/tools/test_tool_executor.py index c78e12bc..f87ffd62 100644 --- a/tests/tools/test_tool_executor.py +++ b/tests/tools/test_tool_executor.py @@ -113,6 +113,42 @@ async def execute(self, *, tool_input, context): assert len(results) == 5 assert all(r.content == "read result" for r in results) + async def test_preserves_tool_context_read_roots(self): + class CapturingReadTool(FakeReadTool): + async def execute(self, *, tool_input, context): + roots = ",".join(context.trusted_read_directories) + return ToolResult.success(roots) + + read_tool = CapturingReadTool() + registry = MagicMock() + registry.get = lambda name: read_tool + executor = ToolExecutor(registry=registry) + + results = await executor.execute_batch( + [ToolCallRequest(id="read-1", name="read", input={})], + ToolContext(trusted_read_directories=["/tmp/skill-root"]), + ) + + assert results[0].content == "/tmp/skill-root" + + async def test_preserves_tool_context_relative_read_roots(self): + class CapturingReadTool(FakeReadTool): + async def execute(self, *, tool_input, context): + roots = ",".join(context.relative_read_directories) + return ToolResult.success(roots) + + read_tool = CapturingReadTool() + registry = MagicMock() + registry.get = lambda name: read_tool + executor = ToolExecutor(registry=registry) + + results = await executor.execute_batch( + [ToolCallRequest(id="read-1", name="read", input={})], + ToolContext(relative_read_directories=["/tmp/skill-root"]), + ) + + assert results[0].content == "/tmp/skill-root" + async def test_serial_order(self): order = [] From 521d027d80ddb960e9c121d17cc77106738b519b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 22 Jun 2026 18:04:33 +0800 Subject: [PATCH 12/59] docs: record fix-misc cherry-pick --- docs/batch/20260622-fix-misc-cherry-pick.md | 67 +++++++++++++++++++ .../i18n/locales/de/LC_MESSAGES/messages.po | 19 ++++++ .../i18n/locales/es/LC_MESSAGES/messages.po | 19 ++++++ .../i18n/locales/fr/LC_MESSAGES/messages.po | 19 ++++++ .../i18n/locales/ja/LC_MESSAGES/messages.po | 18 +++++ .../i18n/locales/pt/LC_MESSAGES/messages.po | 19 ++++++ .../i18n/locales/zh/LC_MESSAGES/messages.po | 18 +++++ 7 files changed, 179 insertions(+) create mode 100644 docs/batch/20260622-fix-misc-cherry-pick.md diff --git a/docs/batch/20260622-fix-misc-cherry-pick.md b/docs/batch/20260622-fix-misc-cherry-pick.md new file mode 100644 index 00000000..5e00192b --- /dev/null +++ b/docs/batch/20260622-fix-misc-cherry-pick.md @@ -0,0 +1,67 @@ +# 2026-06-22 fix-misc cherry-pick + +## Summary + +Cherry-picked the latest four commits from the `codex/fix-misc` worktree into +`fix_pipeline`: + +- `ae75419 fix: refine pipeline memory policy` +- `e786fa5 fix: expose pipeline docs in website navigation` +- `5fd54e8 fix: remove static pipeline rollback rules` +- `84fd3fa fix: resolve pipeline skill reference paths` + +## What Changed + +### Pipeline Memory Policy + +- Removed automatic full auto-memory injection from pipeline step agent loops. +- Kept memory access model-driven through explicit `read_memory` tool use. +- Added prompt guidance for intent parsing and architecture planning so those + steps can choose memory when it is useful. +- Updated tests covering pipeline memory policy and REPL pipeline memory setup. + +### Website Pipeline Documentation Navigation + +- Added pipeline documentation entries to the website navbar and footer. +- Updated localized Docusaurus navbar/footer metadata. +- Added a navigation test to prevent pipeline docs from disappearing again. + +### Pipeline Rollback Rules + +- Removed static per-step rollback restrictions from the pipeline schema and + selling pipeline configuration. +- Simplified rollback handling to rely on the supported dynamic rollback path. +- Updated engine, state machine, runner, and related tests for unrestricted + rollback behavior. + +### Pipeline Skill Reference Reads + +- Added pipeline-only relative read roots so step agents can read skill + reference files such as `references/template-parameters.md`. +- Kept normal REPL behavior unchanged: general trusted read roots do not change + relative `read_file` path resolution. +- Preserved `ToolContext` positional compatibility after adding the new context + field. +- Added regression tests for pipeline and non-pipeline read path boundaries. + +## Conflict And i18n Notes + +- The cherry-pick completed without code conflicts. +- No i18n catalog merge conflict occurred. +- `make test` detected that a new `read_memory` msgid was missing from the i18n + catalogs. I ran `make translate`, confirmed the msgid was present in + `messages.pot` and every `messages.po`, filled the new `msgstr` entry for + `zh`, `es`, `fr`, `de`, `ja`, and `pt`, then ran `make translate` again to + regenerate compiled catalogs without dropping existing entries. +- The website localization JSON files from the docs navigation commit were + cherry-picked as committed. + +## Verification + +Run from `/Users/ehzyo/open_repo/iac-code3` after the i18n update: + +```bash +make lint # passed +make format # passed, 740 files left unchanged +make test # passed, 6663 tests +``` diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index f1e41121..9ccbf79e 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -2111,6 +2111,25 @@ msgstr "" msgid "Memory name to read. Omit to list all." msgstr "Name des zu lesenden Speichers. Weglassen, um alle aufzulisten." +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"{base}\n" +"\n" +"Available memories:\n" +"{index}\n" +"\n" +"Call read_memory again with one of these names, or omit name to list all " +"memories." +msgstr "" +"{base}\n" +"\n" +"Verfügbare Speicher:\n" +"{index}\n" +"\n" +"Rufen Sie read_memory erneut mit einem dieser Namen auf, oder lassen Sie " +"name weg, um alle Speicher aufzulisten." + #: src/iac_code/memory/memory_tools.py #, python-brace-format msgid "" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 667ae429..fc93f4ca 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -2116,6 +2116,25 @@ msgstr "" msgid "Memory name to read. Omit to list all." msgstr "Nombre de la memoria que se va a leer. Omítelo para listar todas." +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"{base}\n" +"\n" +"Available memories:\n" +"{index}\n" +"\n" +"Call read_memory again with one of these names, or omit name to list all " +"memories." +msgstr "" +"{base}\n" +"\n" +"Memorias disponibles:\n" +"{index}\n" +"\n" +"Vuelve a llamar a read_memory con uno de estos nombres, u omite name para" +" listar todas las memorias." + #: src/iac_code/memory/memory_tools.py #, python-brace-format msgid "" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index f4beeea5..bf5781cc 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -2117,6 +2117,25 @@ msgstr "" msgid "Memory name to read. Omit to list all." msgstr "Nom de la mémoire à lire. Omettez-le pour tout lister." +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"{base}\n" +"\n" +"Available memories:\n" +"{index}\n" +"\n" +"Call read_memory again with one of these names, or omit name to list all " +"memories." +msgstr "" +"{base}\n" +"\n" +"Mémoires disponibles :\n" +"{index}\n" +"\n" +"Appelez à nouveau read_memory avec l’un de ces noms, ou omettez name pour" +" lister toutes les mémoires." + #: src/iac_code/memory/memory_tools.py #, python-brace-format msgid "" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 3822cdd8..56bda993 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -2047,6 +2047,24 @@ msgstr "永続メモリを読み取ります。すべてを一覧表示するに msgid "Memory name to read. Omit to list all." msgstr "読み取るメモリ名。すべてを一覧表示するには省略します。" +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"{base}\n" +"\n" +"Available memories:\n" +"{index}\n" +"\n" +"Call read_memory again with one of these names, or omit name to list all " +"memories." +msgstr "" +"{base}\n" +"\n" +"利用可能なメモリ:\n" +"{index}\n" +"\n" +"これらの名前のいずれかを指定して read_memory を再度呼び出すか、すべてのメモリを一覧表示するには name を省略してください。" + #: src/iac_code/memory/memory_tools.py #, python-brace-format msgid "" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 27cf2c1b..96181552 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -2105,6 +2105,25 @@ msgstr "" msgid "Memory name to read. Omit to list all." msgstr "Nome da memória a ler. Omita para listar todas." +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"{base}\n" +"\n" +"Available memories:\n" +"{index}\n" +"\n" +"Call read_memory again with one of these names, or omit name to list all " +"memories." +msgstr "" +"{base}\n" +"\n" +"Memórias disponíveis:\n" +"{index}\n" +"\n" +"Chame read_memory novamente com um destes nomes, ou omita name para " +"listar todas as memórias." + #: src/iac_code/memory/memory_tools.py #, python-brace-format msgid "" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 2ccbdcd3..4efa1cf3 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -2033,6 +2033,24 @@ msgstr "读取持久记忆。省略 name 可列出全部,提供 name 可读取 msgid "Memory name to read. Omit to list all." msgstr "要读取的记忆名称。省略则列出全部。" +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"{base}\n" +"\n" +"Available memories:\n" +"{index}\n" +"\n" +"Call read_memory again with one of these names, or omit name to list all " +"memories." +msgstr "" +"{base}\n" +"\n" +"可用记忆:\n" +"{index}\n" +"\n" +"请使用其中一个名称再次调用 read_memory,或省略 name 以列出所有记忆。" + #: src/iac_code/memory/memory_tools.py #, python-brace-format msgid "" From c4f9714cb562e5550e5423c99f20ec74ba9d4697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 22 Jun 2026 10:33:46 +0800 Subject: [PATCH 13/59] fix: refine aliyun selling pipeline skills --- .../pipeline/engine/pipeline_runner.py | 7 +- src/iac_code/pipeline/engine/ui_contract.py | 57 ++++++-- .../pipeline/selling/hooks/deploying.py | 41 +++++- src/iac_code/pipeline/selling/pipeline.yaml | 3 + .../selling/prompts/confirm_and_select.md | 13 ++ .../selling/prompts/cost_estimating.md | 7 +- .../pipeline/selling/prompts/deploying.md | 10 +- .../selling/skills/iac-aliyun-cost/SKILL.md | 92 +++++++----- .../selling/skills/iac-aliyun-cost/evals.json | 64 +++++++-- .../skills/iac-aliyun-deploying/SKILL.md | 17 ++- .../skills/iac-aliyun-deploying/evals.json | 21 ++- .../iac-aliyun-template-generating/SKILL.md | 34 +---- .../test_pipeline_runner_sidecar_path.py | 31 ++++ tests/pipeline/engine/test_ui_contract.py | 36 +++++ .../skills/test_iac_aliyun_cost_skill.py | 136 +++++++++++++++++- .../skills/test_iac_aliyun_deploying_skill.py | 89 +++++++++++- .../skills/test_template_generating_skill.py | 24 +++- tests/pipeline/selling/test_deploying_hook.py | 58 ++++++++ .../selling/test_terminal_ui_contract.py | 21 +++ 19 files changed, 641 insertions(+), 120 deletions(-) diff --git a/src/iac_code/pipeline/engine/pipeline_runner.py b/src/iac_code/pipeline/engine/pipeline_runner.py index aa5cf407..e2959f8e 100644 --- a/src/iac_code/pipeline/engine/pipeline_runner.py +++ b/src/iac_code/pipeline/engine/pipeline_runner.py @@ -32,7 +32,7 @@ from iac_code.pipeline.engine.step_spec import AllowUserEscapes, LoadedPipeline, OnCompletePolicy, StepSpec from iac_code.pipeline.engine.sub_pipeline_executor import SubPipelineExecutor from iac_code.pipeline.engine.types import StepResult, StepStatus -from iac_code.pipeline.engine.ui_contract import PipelineStepType +from iac_code.pipeline.engine.ui_contract import PipelineStepType, parse_selected_candidate from iac_code.pipeline.engine.user_input import ( PipelineInputContent, PipelineUserInput, @@ -1708,6 +1708,11 @@ def _option_display_value(option: Any) -> str | None: return None def _infer_selected_index(self, selected_value: str, options: list[Any]) -> int | None: + structured = parse_selected_candidate(selected_value) + if structured is not None and structured.selected_candidate_index is not None: + idx = structured.selected_candidate_index + if 0 <= idx < len(options): + return idx matches = [idx for idx, option in enumerate(options) if self._option_display_value(option) == selected_value] if len(matches) == 1: return matches[0] diff --git a/src/iac_code/pipeline/engine/ui_contract.py b/src/iac_code/pipeline/engine/ui_contract.py index 459dcc9d..c40e9d99 100644 --- a/src/iac_code/pipeline/engine/ui_contract.py +++ b/src/iac_code/pipeline/engine/ui_contract.py @@ -4,7 +4,7 @@ import json import re -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from typing import Any @@ -33,22 +33,28 @@ class SelectedCandidate: selected_candidate_name: str selected_candidate_index: int | None = None + parameter_overrides: dict[str, Any] = field(default_factory=dict) -def encode_selected_candidate(candidate_name: str, candidate_index: int | None) -> str: - return json.dumps( - { - "selected_candidate_name": candidate_name, - "selected_candidate_index": candidate_index, - }, - ensure_ascii=False, - ) +def encode_selected_candidate( + candidate_name: str, + candidate_index: int | None, + parameter_overrides: dict[str, Any] | None = None, +) -> str: + payload: dict[str, Any] = { + "selected_candidate_name": candidate_name, + "selected_candidate_index": candidate_index, + } + if parameter_overrides: + payload["parameter_overrides"] = parameter_overrides + return json.dumps(payload, ensure_ascii=False) def parse_selected_candidate(value: Any) -> SelectedCandidate | None: if isinstance(value, dict): name = value.get("selected_candidate_name") index = value.get("selected_candidate_index") + parameter_overrides = _parse_parameter_overrides(value) elif isinstance(value, str): stripped = value.strip() if not stripped: @@ -64,9 +70,12 @@ def parse_selected_candidate(value: Any) -> SelectedCandidate | None: return None name = decoded.get("selected_candidate_name") index = decoded.get("selected_candidate_index") + parameter_overrides = _parse_parameter_overrides(decoded) else: return None + if parameter_overrides is None: + return None if index is not None and not isinstance(index, int): return None if isinstance(name, str) and name.strip(): @@ -75,7 +84,11 @@ def parse_selected_candidate(value: Any) -> SelectedCandidate | None: candidate_name = "" else: return None - return SelectedCandidate(selected_candidate_name=candidate_name, selected_candidate_index=index) + return SelectedCandidate( + selected_candidate_name=candidate_name, + selected_candidate_index=index, + parameter_overrides=parameter_overrides, + ) def _parse_candidate_index_hint(value: str) -> int | None: @@ -88,3 +101,27 @@ def _parse_candidate_index_hint(value: str) -> int | None: except ValueError: return None return None + + +def _parse_parameter_overrides(payload: dict[str, Any]) -> dict[str, Any] | None: + raw: Any = None + found = False + for key in ("parameter_overrides", "deployment_parameters", "parameters"): + if key in payload: + raw = payload.get(key) + found = True + break + + if not found or raw is None: + return {} + if not isinstance(raw, dict): + return None + + overrides: dict[str, Any] = {} + for key, value in raw.items(): + if not isinstance(key, str) or not key.strip(): + return None + if value is None: + continue + overrides[key.strip()] = value + return overrides diff --git a/src/iac_code/pipeline/selling/hooks/deploying.py b/src/iac_code/pipeline/selling/hooks/deploying.py index 12c6161e..4b286230 100644 --- a/src/iac_code/pipeline/selling/hooks/deploying.py +++ b/src/iac_code/pipeline/selling/hooks/deploying.py @@ -1,7 +1,7 @@ """Hook for the deploying step.""" import time -from dataclasses import asdict, dataclass +from dataclasses import dataclass from typing import Any from iac_code.pipeline.engine.cleanup import CleanupLedger, CleanupResource, ObservedResource @@ -79,7 +79,7 @@ def normalize_selected_plan( candidates = evaluated_candidates or [] resolution = resolve_selected_candidate(selected, candidates) - plan["selection"] = asdict(selected) + plan["selection"] = _selection_dict(selected) if resolution.error: plan["selection_valid"] = False plan["selection_error"] = resolution.error @@ -88,18 +88,53 @@ def normalize_selected_plan( plan["selection_valid"] = True plan["selected_candidate"] = resolution.candidate plan["selected_candidate_result"] = resolution.result + plan["parameter_overrides"] = dict(selected.parameter_overrides) + effective_parameters = _effective_deployment_parameters(resolution.result, selected.parameter_overrides) + if effective_parameters: + plan["effective_deployment_parameters"] = effective_parameters + plan["cost_estimate_parameter_overridden"] = bool(selected.parameter_overrides) return plan def _selection_payload(plan: dict[str, Any]) -> Any: if "selected_candidate_index" in plan or "selected_candidate_name" in plan: - return { + payload = { "selected_candidate_name": plan.get("selected_candidate_name", ""), "selected_candidate_index": plan.get("selected_candidate_index"), } + if "parameter_overrides" in plan: + payload["parameter_overrides"] = plan.get("parameter_overrides") + elif "parameters" in plan: + payload["parameters"] = plan.get("parameters") + return payload return plan.get("user_input") +def _selection_dict(selected: SelectedCandidate) -> dict[str, Any]: + data: dict[str, Any] = { + "selected_candidate_name": selected.selected_candidate_name, + "selected_candidate_index": selected.selected_candidate_index, + } + if selected.parameter_overrides: + data["parameter_overrides"] = dict(selected.parameter_overrides) + return data + + +def _effective_deployment_parameters( + selected_candidate_result: dict[str, Any] | None, + parameter_overrides: dict[str, Any], +) -> dict[str, Any]: + parameters: dict[str, Any] = {} + if isinstance(selected_candidate_result, dict): + cost = selected_candidate_result.get("cost") + if isinstance(cost, dict): + deployment_parameters = cost.get("deployment_parameters") + if isinstance(deployment_parameters, dict): + parameters.update(deployment_parameters) + parameters.update(parameter_overrides) + return parameters + + def on_enter(ctx: PipelineContext) -> None: """Resolve the structured selected candidate before rendering the deploying prompt.""" selected_plan = ctx.get_conclusion("selected_plan") diff --git a/src/iac_code/pipeline/selling/pipeline.yaml b/src/iac_code/pipeline/selling/pipeline.yaml index e3ac39fa..05685803 100644 --- a/src/iac_code/pipeline/selling/pipeline.yaml +++ b/src/iac_code/pipeline/selling/pipeline.yaml @@ -235,6 +235,9 @@ steps: selected_candidate_index: type: integer description: 用户最终选择的候选方案在 evaluated_candidates 中的 0 基下标;首次展示方案时可省略 + parameter_overrides: + type: object + description: 用户选择方案时传入的部署参数覆盖字典;键为 ROS Parameters 名称,值为用户指定的部署参数值;首次展示方案时可省略 - id: deploying description: "执行用户选定方案的部署" diff --git a/src/iac_code/pipeline/selling/prompts/confirm_and_select.md b/src/iac_code/pipeline/selling/prompts/confirm_and_select.md index 5b351f86..407521a9 100644 --- a/src/iac_code/pipeline/selling/prompts/confirm_and_select.md +++ b/src/iac_code/pipeline/selling/prompts/confirm_and_select.md @@ -14,6 +14,18 @@ 如果当前用户消息是在选择方案(例如包含“选择方案0”、“方案1”、候选方案名称,或表达“选便宜/高可用/已有VPC”等偏好),不要再次展示所有方案,也不要再次调用展示工具;请直接根据用户输入和上方 `evaluated_candidates` 判断最终选择,并调用 `complete_step` 提交最终结论。 +如果当前用户消息是结构化 JSON 选择消息,例如: +```json +{ + "selected_candidate_index": 0, + "parameter_overrides": { + "ZoneId": "cn-hangzhou-k", + "InstanceType": "ecs.g7.large" + } +} +``` +如果用户选择方案时传入 `parameter_overrides`(或兼容字段 `parameters`),必须原样整理为 `parameter_overrides` 放入最终结论;不要写入模板 Default,也不要在本步骤重新询价。 + 如果当前没有用户选择消息,按以下流程展示候选方案并等待用户选择。 对每个 `failed` 为 `false` 的方案,依次调用以下两个工具: @@ -52,6 +64,7 @@ - `user_input`:用户本次选择的原始文本 - `selected_candidate_name`:最终选择的候选方案名称,必须取 `candidate.name` - `selected_candidate_index`:最终选择的候选方案在 `evaluated_candidates` 数组中的 0 基下标 +- `parameter_overrides`:用户选择方案时传入的部署参数覆盖字典;没有传入时可省略 如果用户输入可以明确映射到某个方案编号(例如“方案0”),按 0 基下标选择对应方案。 如果用户输入匹配某个候选方案名称,选择该方案。 diff --git a/src/iac_code/pipeline/selling/prompts/cost_estimating.md b/src/iac_code/pipeline/selling/prompts/cost_estimating.md index 78408a86..5e3f142f 100644 --- a/src/iac_code/pipeline/selling/prompts/cost_estimating.md +++ b/src/iac_code/pipeline/selling/prompts/cost_estimating.md @@ -1,6 +1,6 @@ # 步骤:成本预估 -你正在为候选方案预估部署费用。使用 ROS `GetTemplateEstimateCost` API 获取费用预估。 +你正在为候选方案预估部署费用。优先通过 `aliyun_api(product="ros", action="PreviewStack")` 形成 Preview-Validated Pricing Parameter Set,不要使用 `ros_stack` 执行 `PreviewStack`;PreviewStack 不是硬门禁,若完整部署参数暂时无法自动补齐,记录参数缺口后可用当前已选参数调用 ROS `GetTemplateEstimateCost` API 获取费用预估。 ## 模板信息 - 文件路径:`{template.file_path}` @@ -14,10 +14,5 @@ ## 输出 API 调用完成后调用 `complete_step` 提交费用预估。 -补充说明: -- `cost` 字段为字符串,包含金额和计费周期(如 "¥800/月") -- 若修复了模板,设置 `template_fixed: true` 并在 `fix_summary` 中说明 -- 询价失败时 `monthly_estimate` 填 "询价失败",`error` 说明原因 - ## 注意事项 - 不要读取项目文件或记忆,所需的上下文已在上方提供。 diff --git a/src/iac_code/pipeline/selling/prompts/deploying.md b/src/iac_code/pipeline/selling/prompts/deploying.md index 942a826b..710cf399 100644 --- a/src/iac_code/pipeline/selling/prompts/deploying.md +++ b/src/iac_code/pipeline/selling/prompts/deploying.md @@ -3,7 +3,9 @@ 你正在执行 AI 售卖流程的最终步骤:将用户选择的方案模板部署到阿里云。 ## 部署执行 -用户已在上一步确认选择了该方案,该选择等价于本步骤的部署确认。不要再次询问是否确认部署,也不要询问是否确认部署参数。完成模板校验、可用性查询和参数选择后,直接调用 `ros_stack` 执行部署。 +用户已在上一步确认选择了该方案,该选择等价于本步骤的部署确认。不要再次询问是否确认部署,也不要询问是否确认部署参数。完成模板校验、可用性查询和参数装配后,直接调用 `ros_stack` 执行部署。 + +上述确认只适用于部署执行,不适用于删除已有 Stack。删除请求本身不等于删除确认;只有用户明确回复“确认删除”“我确认删除”等删除确认语句,或上下文显式提供 `delete_confirmed: true` 时,才可执行删除。未收到明确删除确认前,不得调用 `ros_stack` 的 `DeleteStack`。 ## 原始用户需求与约束 部署时必须继续遵守原始用户需求中的地域、资源命名、StackName、是否复用已有资源等约束。如果这些约束与候选方案、模板文件名或默认参数冲突,以原始用户需求为准。 @@ -26,6 +28,8 @@ `selected_plan.selection_valid` 为 `true` 时,使用 `selected_plan.selected_candidate` 和 `selected_plan.selected_candidate_result` 中的模板、费用、审查信息进行部署。 +部署参数装配规则见技能。部署步骤不计算费用。 + 如果 `selected_plan.selection_valid` 为 `false`,不要部署。调用 `rollback_request` 回到 `confirm_and_select`,reason 使用 `selected_plan.selection_error`。 @@ -35,12 +39,8 @@ ## 输出 部署完成后调用 `complete_step` 提交部署结果。 -- 不得用 status: cancelled 表示等待用户确认。 -- 只有用户明确取消部署时,才可以提交 `status: cancelled`。 -- 如果因为权限、配额、参数或云产品限制导致无法部署,提交 `status: failed` 并说明原因;需要架构变更时使用 rollback_request。 ## 错误处理 -- 可用区不可用 → 自动更换可用区重试 - 模板校验失败 → 就地修复模板后重试(最多 5 轮) - 架构层面必须变更(如产品组合不可行)→ rollback_request 到 `architecture_planning` diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md index 7ad6a078..2d379ec4 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md @@ -5,7 +5,7 @@ when_to_use: 当需要对阿里云 ROS 模板进行费用预估时 user_invocable: false conclusion_schema: type: object - required: [monthly_estimate, currency, resources, template_fixed] + required: [monthly_estimate, currency, resources, template_fixed, deployment_parameters] additionalProperties: false properties: monthly_estimate: @@ -26,6 +26,22 @@ conclusion_schema: type: string template_fixed: type: boolean + deployment_parameters: + type: object + description: 当前已选、已验证或已用于询价并传递给 deploying 的模板参数字典;可由后续选择阶段补充覆盖 + missing_deployment_parameters: + type: array + description: PreviewStack 或最终部署仍缺少、需要用户在后续选择阶段补充的参数 + items: + type: object + required: [name, reason] + properties: + name: + type: string + reason: + type: string + parameter_set_summary: + type: string fix_summary: type: string error: @@ -38,16 +54,18 @@ conclusion_schema: 使用阿里云 ROS `GetTemplateEstimateCost` API 预估部署费用。 -前一步已完成模板校验;本步骤先直接询价,避免在成本预估前重复校验。只有在修复或改写模板后,才调用 `ValidateTemplate` 校验改动。 +前一步已完成模板校验;本步骤避免在成本预估前重复校验模板。首次询价前优先按参数推荐流程形成 Preview-Validated Pricing Parameter Set,再调用询价 API。PreviewStack 不是硬门禁;完整部署参数暂时无法自动形成时,仍可用当前已选参数调用询价 API,并把缺口留给后续选择阶段补充。只有在修复或改写模板后,才调用 `ValidateTemplate` 校验改动。 ## 执行流程 1. **解析模板** — 从上下文的 `template` 字段获取模板内容和文件路径 2. **提取参数** — 从模板 Parameters 中提取所有参数及其默认值 -3. **调用询价 API** — 使用 `GetTemplateEstimateCost` 获取费用预估 -4. **按需修复问题** — 仅当询价失败且错误指向模板问题,或你必须修复/改写模板时,修改模板并写回原文件路径 -5. **修改后校验并重新询价** — 调用 `ValidateTemplate` 校验改动;通过后调用 `GetTemplateEstimateCost` 重新询价;失败则修复重试(最多 7 轮) -6. **输出结果** — 汇总费用并调用 `complete_step` +3. **推荐并预览验证询价参数** — 按「询价参数推荐与传递」优先形成 Preview-Validated Pricing Parameter Set,不得跳过约束求解直接编造库存值 +4. **调用询价 API** — 优先使用 Preview-Validated Pricing Parameter Set;若 PreviewStack 因完整部署参数缺口无法通过,可用当前已选或可用于询价的参数调用 `GetTemplateEstimateCost` +5. **按需修复问题** — 仅当询价失败且错误指向模板问题,或你必须修复/改写模板时,修改模板并写回原文件路径 +6. **修改后校验并重新询价** — 调用 `ValidateTemplate` 校验改动;通过后调用 `GetTemplateEstimateCost` 重新询价;失败则修复重试(最多 7 轮) +7. **结构化传递参数** — 在 `complete_step.conclusion.deployment_parameters` 输出当前已选或已用于询价的参数字典;在 `missing_deployment_parameters` 输出仍需用户补充的完整部署参数缺口 +8. **输出结果** — 汇总费用并调用 `complete_step` ## 按需校验模板 @@ -75,9 +93,23 @@ aliyun_api( > **TemplateURL 支持本地文件路径**:`TemplateURL` 可传本地路径(如 `/tmp/template.yml`),工具会自动读取文件内容。避免将大模板内容直接作为参数传递。 +## 询价参数推荐与传递 + +缺少 Default 或上下文值时,按 [references/template-parameter-recommendation.md](references/template-parameter-recommendation.md) 的参数推荐规则求解,并优先通过 `aliyun_api(product="ros", action="PreviewStack")` 形成 **Preview-Validated Pricing Parameter Set**。不要使用 `ros_stack` 执行 `PreviewStack`;本步骤只验证参数与模板可预览,不执行部署确认或 `CreateStack`。 + +PreviewStack 不是硬门禁。它要求完整部署参数,常比 `GetTemplateEstimateCost` 需要更多外部输入;如果完整部署参数无法自动补齐、或 PreviewStack 因外部参数缺口失败,但已有参数足以询价,则可以调用 `GetTemplateEstimateCost` 估算费用。此时必须在 `parameter_set_summary` 说明 PreviewStack 状态,在 `missing_deployment_parameters` 列出缺口,后续选择阶段可通过 `parameter_overrides` 补齐,deploying 再做最终部署校验。 + +本步骤的裁剪规则: +- 优先使用上下文已有值和模板 Default;库存相关参数缺值时,先通过 `GetTemplateParameterConstraints` 获取合法 `AllowedValues`,必要时再按 [references/cloud-products/](references/cloud-products/) 的可用性 API 与选型策略补足。 +- VpcId、VSwitchId、SecurityGroupId、KeyPairName 等已有资源参数:先查询约束或只读资源候选;API 返回候选不是编造,可作为参数候选参与回溯与 PreviewStack。没有上下文值、模板 Default、用户提供值或 API 返回候选时,才按外部输入缺失处理。 +- 只能在合法候选内筛选或排序,不得编造 API 未返回的库存值;LicenseKey、Token、证书、真实域名等外部输入不得编造。不要仅因参数名是 VpcId、VSwitchId、SecurityGroupId 或 KeyPairName 就跳过参数推荐并直接停止询价。 +- `PreviewStack` 因候选组合不可行失败时,按 reference 的回溯规则更换候选;因外部输入缺失失败时,记录缺口,不用占位值伪造,并按上方软门禁规则决定是否继续询价。 +- 最终得到的参数集不写入模板 `Default`;将当前已选、已验证或已用于询价的参数作为结构化数据放入 `complete_step.conclusion.deployment_parameters`,传递给 deploying。模板 Default 只是参数求解的输入来源之一,不是跨步骤传参介质。 +- PreviewStack 成功但询价失败时,不要丢弃 Preview-Validated Pricing Parameter Set;仍在 `deployment_parameters` 输出该参数集,同时如实报告询价失败原因。 + ## 调用询价 API -通过 `TemplateURL` 传递模板文件路径(不要用 `TemplateBody` 内联模板内容,模板可能很大)。模板参数必须按 `Parameters..ParameterKey` / `Parameters..ParameterValue` 平铺(下标从 1 起),不要把参数名作为顶层 key 传入: +通过 `TemplateURL` 传递模板文件路径(不要用 `TemplateBody` 内联模板内容,模板可能很大)。ROS API 的 `Parameters` 直接传字典格式,工具会自动展开为 API 所需的平铺参数;不要手动展开: ```python aliyun_api( @@ -85,22 +117,22 @@ aliyun_api( action="GetTemplateEstimateCost", params={ "TemplateURL": "/tmp/ros-template.yml", - "Parameters.1.ParameterKey": "ZoneId", - "Parameters.1.ParameterValue": "cn-hangzhou-k", - "Parameters.2.ParameterKey": "InstanceType", - "Parameters.2.ParameterValue": "ecs.g7.large", - "Parameters.3.ParameterKey": "ImageId", - "Parameters.3.ParameterValue": "centos_stream_9_x64_20G_alibase_20260414.vhd", - "Parameters.4.ParameterKey": "SystemDiskCategory", - "Parameters.4.ParameterValue": "cloud_essd", + "Parameters": { + "ZoneId": "cn-hangzhou-k", + "InstanceType": "ecs.g7.large", + "ImageId": "centos_stream_9_x64_20G_alibase_20260414.vhd", + "SystemDiskCategory": "cloud_essd", + }, }, region_id="cn-hangzhou", ) ``` 参数值来源: -- 模板 Parameters 中有 Default 值的 → 使用默认值 -- 没有默认值的库存相关参数(ZoneId、InstanceType 等)→ 使用上下文中提供的值或合理默认值 +- 上下文中已有部署/可用性选择结果的 → 使用上下文值 +- 模板 Parameters 中有 Default 值且上下文未覆盖的 → 使用默认值 +- 没有 Default 的库存相关参数(ZoneId、InstanceType 等)→ 按「询价参数推荐与传递」求解,不要直接编造 +- PreviewStack 成功时,最终用于询价的参数集必须与 PreviewStack 验证通过的参数集一致;PreviewStack 未通过但继续询价时,`deployment_parameters` 填当前已用于询价的参数,`missing_deployment_parameters` 填完整部署参数缺口 ## ROS 模板修复参考 @@ -112,27 +144,6 @@ aliyun_api( | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | 修复 Parameters 定义(缺少 AssociationProperty、Label 等)时 | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | 修复资源定义、内置函数用法等模板结构问题时 | -### 参数化规则 - -以下属性必须定义为 Parameters: - -| 产品 | 须参数化的属性 | -|------|---------------| -| ECS | ZoneId, InstanceType, ImageId, SystemDiskCategory, DataDiskCategory | -| RDS | ZoneId, DBInstanceClass, DBInstanceStorageType | -| Redis | ZoneId, InstanceClass | -| SLB/ALB | ZoneId | - -### 常用资源类型 - -- ALIYUN::ECS::VPC — 专有网络 -- ALIYUN::ECS::VSwitch — 交换机 -- ALIYUN::ECS::SecurityGroup — 安全组 -- ALIYUN::ECS::InstanceGroup — ECS 实例(通过 MaxAmount 指定数量) -- ALIYUN::RDS::DBInstance — RDS 数据库实例 -- ALIYUN::REDIS::Instance — Redis 缓存实例 -- ALIYUN::SLB::LoadBalancer — 负载均衡 - ### 查询资源属性 Schema 不确定资源属性时: @@ -154,5 +165,8 @@ aliyun_api(product="ros", action="GetResourceType", params={"ResourceType": "< 补充说明: - `cost` 字段为字符串,包含金额和计费周期(如 "¥800/月"、"¥0.5/小时"、"¥0") -- 若修复了模板,设置 `template_fixed: true` 并在 `fix_summary` 中说明修复内容 +- 若修复了模板,设置 `template_fixed: true` 并在 `fix_summary` 中说明修复内容;仅形成或输出 `deployment_parameters` 不算模板修复 +- `deployment_parameters` 填当前已选、已验证或已用于 `GetTemplateEstimateCost` 的参数字典;PreviewStack 成功但询价失败时仍填该参数集;没有任何可用参数时填 `{}` +- `missing_deployment_parameters` 填完整部署或 PreviewStack 仍缺少的参数及原因;没有缺口时可省略或填 `[]` +- `parameter_set_summary` 可简要说明参数来源、可用性筛选、PreviewStack 验证结果以及是否使用软门禁继续询价 - 询价失败时 `monthly_estimate` 填 "询价失败",`resources` 为空数组,`error` 说明原因 diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/evals.json b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/evals.json index 09af2187..cd40c1a0 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/evals.json +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/evals.json @@ -1,6 +1,6 @@ { "skill_name": "iac-aliyun-cost", - "description": "验证成本预估技能:正确调用 GetTemplateEstimateCost API、参数平铺格式、模板校验修复、错误处理", + "description": "验证成本预估技能:正确调用 GetTemplateEstimateCost API、Parameters 字典格式、参数推荐传递、模板按需校验修复、错误处理", "evals": [ { "id": 1, @@ -11,12 +11,14 @@ "file_path": "templates/1-simple-ecs.yml", "region": "cn-hangzhou" }, - "expected_behavior": "直接调用 GetTemplateEstimateCost,参数按 Parameters..ParameterKey/ParameterValue 平铺传递", + "expected_behavior": "先形成 Preview-Validated Pricing Parameter Set,再调用 GetTemplateEstimateCost,Parameters 以字典形式传递,并在 conclusion.deployment_parameters 输出同一参数集", "assertions": [ + {"name": "uses_preview_stack_api", "check": "PreviewStack 通过 aliyun_api(product=\"ros\", action=\"PreviewStack\") 调用,不使用 ros_stack"}, {"name": "uses_estimate_cost_api", "check": "调用了 GetTemplateEstimateCost API"}, - {"name": "params_flattened", "check": "参数使用 Parameters.1.ParameterKey / Parameters.1.ParameterValue 格式平铺"}, + {"name": "params_dictionary", "check": "参数使用 Parameters 字典格式传递,由工具展开 API 参数"}, {"name": "uses_template_url", "check": "通过 TemplateURL 传递模板文件路径而非内联模板内容"}, {"name": "outputs_monthly_estimate", "check": "conclusion 中包含 monthly_estimate 和 resources 费用明细"}, + {"name": "outputs_deployment_parameters", "check": "conclusion.deployment_parameters 包含最终用于询价的参数字典,供 deploying 使用"}, {"name": "no_doc_search", "check": "不调用 aliyun_doc_search 或搜索定价文档"} ] }, @@ -29,11 +31,13 @@ "file_path": "templates/2-ecs-rds.yml", "region": "cn-beijing" }, - "expected_behavior": "ECS 和 RDS 的参数都通过平铺格式传入询价 API,费用明细分别列出", + "expected_behavior": "ECS 和 RDS 的参数都通过 Parameters 字典传入询价 API,费用明细分别列出,并输出 deployment_parameters", "assertions": [ + {"name": "uses_preview_stack_api", "check": "PreviewStack 通过 aliyun_api(product=\"ros\", action=\"PreviewStack\") 调用,不使用 ros_stack"}, {"name": "uses_estimate_cost_api", "check": "调用了 GetTemplateEstimateCost API"}, {"name": "all_params_included", "check": "所有 6 个参数(ZoneId、InstanceType、ImageId、SystemDiskCategory、DBInstanceClass、DBInstanceStorageType)都传入 API"}, {"name": "multi_resource_breakdown", "check": "费用明细中分别列出了 ECS 和 RDS 的费用"}, + {"name": "outputs_deployment_parameters", "check": "conclusion.deployment_parameters 包含最终用于询价的完整参数字典"}, {"name": "no_self_estimate", "check": "不自行估算费用数字,完全基于 API 返回"} ] }, @@ -47,12 +51,13 @@ "region": "cn-hangzhou", "description": "VSwitch 缺少 CidrBlock,ImageId 引用了不存在的参数" }, - "expected_behavior": "先校验发现模板问题,修复(补 CidrBlock、修正 ImageId 参数引用),再询价", + "expected_behavior": "先形成 Preview-Validated Pricing Parameter Set 并调用询价;当询价失败且错误指向模板问题时,修复(补 CidrBlock、修正 ImageId 参数引用),再校验改动并重新询价", "assertions": [ - {"name": "validates_first", "check": "先调用 ValidateTemplate 校验模板"}, + {"name": "uses_preview_stack_api", "check": "PreviewStack 通过 aliyun_api(product=\"ros\", action=\"PreviewStack\") 调用,不使用 ros_stack"}, + {"name": "validates_after_template_change", "check": "仅在修复或改写模板后调用 ValidateTemplate 校验改动"}, {"name": "fixes_template", "check": "修复了模板中的错误(VSwitch CidrBlock、ImageId 参数引用)"}, {"name": "writes_back", "check": "修复后将模板写回文件"}, - {"name": "retries_after_fix", "check": "修复后重新校验或直接询价"}, + {"name": "retries_after_fix", "check": "修复并校验通过后重新询价"}, {"name": "template_fixed_true", "check": "conclusion 中 template_fixed 为 true 且包含 fix_summary"} ] }, @@ -66,25 +71,66 @@ "region": "cn-shanghai", "description": "仅 VPC+OSS 的模板,OSS 可能不支持询价" }, - "expected_behavior": "询价 API 可能返回错误(OSS 不支持询价),应报告错误而不编造费用", + "expected_behavior": "PreviewStack 成功但询价失败(如 OSS 不支持询价)时,应报告错误而不编造费用,同时不丢弃已验证的 deployment_parameters", "assertions": [ + {"name": "uses_preview_stack_api", "check": "PreviewStack 通过 aliyun_api(product=\"ros\", action=\"PreviewStack\") 调用,不使用 ros_stack"}, {"name": "attempts_pricing", "check": "尝试调用了 GetTemplateEstimateCost API"}, {"name": "reports_error_honestly", "check": "如果 API 返回错误,如实报告错误原因"}, {"name": "no_fabricated_cost", "check": "不编造费用数据"}, + {"name": "keeps_preview_parameters", "check": "PreviewStack 成功但询价失败时不丢弃 deployment_parameters"}, {"name": "error_in_conclusion", "check": "conclusion 中包含 error 字段说明失败原因"} ] }, { "id": 5, + "name": "existing-vpc-vswitch-cost", + "prompt": "预估在已有 VPC 中创建 VSwitch 的费用", + "template_context": { + "template": "ROSTemplateFormatVersion: '2015-09-01'\nParameters:\n VpcId:\n Type: String\n AssociationProperty: ALIYUN::ECS::VPC::VPCId\n AssociationPropertyMetadata:\n RegionId: ${ALIYUN::Region}\n ZoneId:\n Type: String\n AssociationProperty: ALIYUN::ECS::ZoneId\n AssociationPropertyMetadata:\n RegionId: ${ALIYUN::Region}\nResources:\n VSwitch:\n Type: ALIYUN::ECS::VSwitch\n Properties:\n VpcId: !Ref VpcId\n ZoneId: !Ref ZoneId\n CidrBlock: 192.168.1.0/24", + "file_path": "templates/5-existing-vpc-vswitch.yml", + "region": "cn-hangzhou", + "description": "VpcId 和 ZoneId 都没有 Default,VpcId 是已有资源参数" + }, + "expected_behavior": "先通过 GetTemplateParameterConstraints 或只读资源候选求解 VpcId/ZoneId;API 返回候选不是编造,可用于 PreviewStack。不要仅因 VpcId 是已有资源参数就直接停止询价;PreviewStack 成功后调用 GetTemplateEstimateCost,并输出 deployment_parameters", + "assertions": [ + {"name": "queries_parameter_constraints", "check": "调用 GetTemplateParameterConstraints 或等价只读查询获取 VpcId/ZoneId 候选"}, + {"name": "api_candidates_not_fabricated", "check": "明确区分 API 返回候选不是编造,不把 VpcId 名称本身当作停止条件"}, + {"name": "uses_preview_stack_api", "check": "PreviewStack 通过 aliyun_api(product=\"ros\", action=\"PreviewStack\") 调用,不使用 ros_stack"}, + {"name": "attempts_pricing", "check": "PreviewStack 成功后调用 GetTemplateEstimateCost API"}, + {"name": "outputs_deployment_parameters", "check": "conclusion.deployment_parameters 包含最终用于 PreviewStack 的 VpcId 和 ZoneId"} + ] + }, + { + "id": 6, + "name": "preview-soft-gate-partial-pricing", + "prompt": "预估使用已有网络部署一台 ECS 的费用,网络参数可以稍后由用户补充", + "template_context": { + "template": "ROSTemplateFormatVersion: '2015-09-01'\nParameters:\n VpcId:\n Type: String\n AssociationProperty: ALIYUN::ECS::VPC::VPCId\n VSwitchId:\n Type: String\n AssociationProperty: ALIYUN::VPC::VSwitch::VSwitchId\n SecurityGroupId:\n Type: String\n AssociationProperty: ALIYUN::ECS::SecurityGroup::SecurityGroupId\n ZoneId:\n Type: String\n Default: cn-hangzhou-k\n InstanceType:\n Type: String\n Default: ecs.g7.large\n ImageId:\n Type: String\n Default: centos_stream_9_x64_20G_alibase_20260414.vhd\n SystemDiskCategory:\n Type: String\n Default: cloud_essd\nResources:\n Ecs:\n Type: ALIYUN::ECS::InstanceGroup\n Properties:\n VpcId: !Ref VpcId\n VSwitchId: !Ref VSwitchId\n SecurityGroupId: !Ref SecurityGroupId\n ZoneId: !Ref ZoneId\n InstanceType: !Ref InstanceType\n ImageId: !Ref ImageId\n SystemDiskCategory: !Ref SystemDiskCategory\n MaxAmount: 1", + "file_path": "templates/6-existing-network-ecs.yml", + "region": "cn-hangzhou", + "description": "PreviewStack 需要完整的已有网络参数,但 ECS 费用可先基于规格和磁盘参数估算" + }, + "expected_behavior": "优先尝试参数推荐和 PreviewStack;如果 VpcId/VSwitchId/SecurityGroupId 无法自动补齐,PreviewStack 不是硬门禁,不要直接终止。可用 ZoneId、InstanceType、ImageId、SystemDiskCategory 等当前可询价参数调用 GetTemplateEstimateCost,并在 conclusion.missing_deployment_parameters 中列出仍需用户通过选择阶段 parameter_overrides 补充的完整部署参数缺口", + "assertions": [ + {"name": "preview_is_soft_gate", "check": "明确说明 PreviewStack 不是硬门禁,不能仅因完整部署参数暂缺就停止询价"}, + {"name": "attempts_pricing_with_available_params", "check": "调用 GetTemplateEstimateCost,用当前已选或可用于询价的参数估算费用"}, + {"name": "reports_missing_deployment_parameters", "check": "conclusion.missing_deployment_parameters 包含 VpcId、VSwitchId、SecurityGroupId 等缺口"}, + {"name": "outputs_partial_deployment_parameters", "check": "conclusion.deployment_parameters 保留已用于询价的 ZoneId、InstanceType、ImageId、SystemDiskCategory"}, + {"name": "mentions_later_overrides", "check": "说明用户后续可在选择阶段通过 parameter_overrides 补充缺失参数"} + ] + }, + { + "id": 7, "name": "ha-slb-ecs-cost", "prompt": "预估高可用方案(SLB + 2台 ECS)的费用", "template_context": { "template": "ROSTemplateFormatVersion: '2015-09-01'\nParameters:\n ZoneId:\n Type: String\n Default: cn-hangzhou-i\n InstanceType:\n Type: String\n Default: ecs.c7.xlarge\n ImageId:\n Type: String\n Default: centos_stream_9_x64_20G_alibase_20260414.vhd\n SystemDiskCategory:\n Type: String\n Default: cloud_essd\nResources:\n Vpc:\n Type: ALIYUN::ECS::VPC\n Properties:\n CidrBlock: 10.0.0.0/16\n VSwitch:\n Type: ALIYUN::ECS::VSwitch\n Properties:\n VpcId: !Ref Vpc\n ZoneId: !Ref ZoneId\n CidrBlock: 10.0.1.0/24\n SecurityGroup:\n Type: ALIYUN::ECS::SecurityGroup\n Properties:\n VpcId: !Ref Vpc\n EcsGroup:\n Type: ALIYUN::ECS::InstanceGroup\n Properties:\n VpcId: !Ref Vpc\n VSwitchId: !Ref VSwitch\n SecurityGroupId: !Ref SecurityGroup\n ZoneId: !Ref ZoneId\n InstanceType: !Ref InstanceType\n ImageId: !Ref ImageId\n SystemDiskCategory: !Ref SystemDiskCategory\n MaxAmount: 2\n Slb:\n Type: ALIYUN::SLB::LoadBalancer\n Properties:\n VpcId: !Ref Vpc\n VSwitchId: !Ref VSwitch\n LoadBalancerSpec: slb.s1.small\n PayType: PayOnDemand", - "file_path": "templates/5-ha-slb-ecs.yml", + "file_path": "templates/7-ha-slb-ecs.yml", "region": "cn-hangzhou" }, "expected_behavior": "正确处理多资源模板,费用明细包含 ECS(x2)和 SLB", "assertions": [ + {"name": "uses_preview_stack_api", "check": "PreviewStack 通过 aliyun_api(product=\"ros\", action=\"PreviewStack\") 调用,不使用 ros_stack"}, {"name": "uses_estimate_cost_api", "check": "调用了 GetTemplateEstimateCost API"}, {"name": "includes_ecs_and_slb", "check": "费用明细中包含 ECS 和 SLB 的费用"}, {"name": "ecs_count_reflected", "check": "ECS 费用描述中体现了 2 台实例(MaxAmount=2)"}, diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md index ae418e1c..3a4fcf07 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md @@ -45,6 +45,7 @@ conclusion_schema: - 当 pipeline prompt 明确说明用户已确认选择/部署时,表示 pipeline 已完成部署确认,不要再次请求用户确认。 - 在已确认的 pipeline 部署步骤中,可展示将使用的 VPC、可用区、网段、Stack 名等参数摘要,但展示后必须继续执行部署,不要询问“是否确认部署”或“是否确认部署参数”。 - 仅当本技能被用户直接触发,或删除/更新等高风险操作没有上层确认时,才需要先询问用户确认;删除/更新操作使用 ⚠️ 警告措辞。 +- 删除请求本身不等于删除确认。只有用户明确回复“确认删除”“我确认删除”等删除确认语句,或上下文显式提供 `delete_confirmed: true` 时,才可执行删除;未收到明确删除确认前,不得调用 `ros_stack` 的 `DeleteStack`。 - `status: cancelled` 只表示用户明确取消部署,不得用 status: cancelled 表示等待用户确认。 ## 模板校验 @@ -64,16 +65,26 @@ conclusion_schema: 查询步骤: 1. 解析模板 Parameters,识别库存相关参数及对应产品 2. 调用各产品可用性 API(具体 API 见 [references/cloud-products/](references/cloud-products/) 各产品文件的「可用性查询」节) -3. 找出公共可用区(所有资源都有库存的可用区) -4. 按 cloud-products 中的推荐规格优先匹配,不可用时选最接近的替代 -5. 得到选定参数;若上层 pipeline 已确认部署,展示选定结果后继续执行,不要再次请求用户确认。 +3. 核对最终部署参数中的可用区和规格是否可用 +4. 参数不可用时先报告冲突详情并尝试调整非用户指定参数;仍无法成功创建资源栈时,才可调整用户指定参数 无法找到公共可用区时,告知用户冲突详情,建议换规格系列或换地域。 +## 部署参数装配 + +CreateStack 前按以下优先级确定 `Parameters`: + +1. `selected_plan.effective_deployment_parameters` 非空时,直接作为最终部署参数集。 +2. 否则使用 `selected_plan.selected_candidate_result.cost.deployment_parameters`。 +3. 仍缺少模板必填参数时,使用模板 Default 或上下文已有值补足;无法补足时返回 `status: failed` 或通过 rollback_request 回到 `confirm_and_select`。 + +装配参数时不得改写模板 `Default`,不得编造缺失的外部输入(LicenseKey、Token、证书、真实域名、已有资源 ID、VpcId、VSwitchId、SecurityGroupId、KeyPairName 等)。参数不可用或 CreateStack 无法成功时,优先调整非用户指定参数;仍无法成功创建资源栈时,才可调整用户指定参数。部署步骤不计算费用。 + ## 执行部署 - 使用 ros_stack 工具执行 CreateStack/UpdateStack/ContinueCreateStack/DeleteStack,禁止用 Bash - CreateStack 必须传 `DisableRollback: true` +- CreateStack 使用装配后的 `Parameters` 字典;不要手动展开为 `Parameters.N.ParameterKey` > **TemplateURL 支持本地文件路径**:ros_stack 中 TemplateURL 可传本地文件路径(如 `/tmp/template.yml`),工具会自动读取文件内容。避免将大模板内容直接作为参数传递。 diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/evals.json b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/evals.json index bdfa50cf..ce3b9525 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/evals.json +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/evals.json @@ -98,11 +98,11 @@ "region": "cn-hangzhou", "stack_id": "stack-to-delete-456" }, - "expected_behavior": "使用 ⚠️ 警告措辞强调删除不可逆,确认后使用 ros_stack DeleteStack", + "expected_behavior": "使用 ⚠️ 警告措辞强调删除不可逆,等待用户明确确认;未确认前不调用 ros_stack DeleteStack", "assertions": [ {"name": "deletion_warning", "check": "使用了 ⚠️ 警告措辞强调删除操作不可逆"}, {"name": "user_confirmation", "check": "等待用户明确确认后才执行删除"}, - {"name": "uses_delete_stack", "check": "调用 ros_stack 的 DeleteStack 操作"} + {"name": "no_delete_without_confirmation", "check": "未收到明确确认前不调用 ros_stack 的 DeleteStack 操作"} ] }, { @@ -169,6 +169,23 @@ {"name": "re_validates_after_fix", "check": "修复后重新调用 ValidateTemplate 确认通过"}, {"name": "deploys_after_validation", "check": "校验通过后才继续执行部署流程"} ] + }, + { + "id": 9, + "name": "delete-stack-confirmed", + "prompt": "确认删除这个 Stack", + "selected_plan": { + "candidate_id": "plan-a", + "region": "cn-hangzhou", + "stack_id": "stack-to-delete-456", + "delete_confirmed": true + }, + "expected_behavior": "用户已明确确认删除后,使用 ros_stack DeleteStack 执行删除,并报告删除请求已提交", + "assertions": [ + {"name": "deletion_warning", "check": "使用了 ⚠️ 警告措辞强调删除操作不可逆"}, + {"name": "uses_delete_stack", "check": "调用 ros_stack 的 DeleteStack 操作"}, + {"name": "completed_success", "check": "调用 complete_step 且 status 为 success"} + ] } ] } diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md index a6ace80f..2e3d8f44 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md @@ -39,10 +39,11 @@ conclusion_schema: 1. 分析架构方案,确定资源列表 2. 查阅 [references/cloud-products/](references/cloud-products/) 下对应产品文件,了解选型策略和库存相关属性 -3. 生成 ROS YAML 模板(库存相关属性按「参数化规则」定义为 Parameters,所有 Parameters 必须添加 AssociationProperty)并写入文件 -4. 调用 aliyun_api(product="ros", action="ValidateTemplate", params={"TemplateURL": <模板文件路径>}) 校验 -5. 校验失败 → 分析错误 → 修复 → 重试(最多 5 轮) -6. 校验通过 → 完成 +3. **必须**阅读 [references/ros-template.md](references/ros-template.md),了解 ROS 模板最佳实践,未阅读不得生成模板 +4. 生成 ROS YAML 模板(库存相关属性按 [references/cloud-products/](references/cloud-products/) 与 [references/template-parameters.md](references/template-parameters.md) 定义为 Parameters,所有 Parameters 必须添加 AssociationProperty)并写入文件 +5. 调用 aliyun_api(product="ros", action="ValidateTemplate", params={"TemplateURL": <模板文件路径>}) 校验 +6. 校验失败 → 分析错误 → 修复 → 重试(最多 5 轮) +7. 校验通过 → 完成 > **TemplateURL 支持本地文件路径**:aliyun_api(product=ros)中,TemplateURL 可传本地文件路径(如 `/tmp/template.yml`),工具会自动读取文件内容。避免将大模板内容直接作为参数传递。 @@ -59,14 +60,7 @@ conclusion_schema: ## 参数化规则 -生成模板时,以下属性**必须**定义为 Parameters(部署前通过 API 查询确定实际值): - -| 产品 | 须参数化的属性 | -|------|---------------| -| ECS | ZoneId, InstanceType, ImageId, SystemDiskCategory, DataDiskCategory | -| RDS | ZoneId, DBInstanceClass, DBInstanceStorageType | -| Redis | ZoneId, InstanceClass | -| SLB/ALB | ZoneId | +生成模板时,库存相关属性**必须**定义为 Parameters(部署前通过 API 查询确定实际值)。具体字段按 [references/cloud-products/](references/cloud-products/) 的产品文件和 [references/template-parameters.md](references/template-parameters.md) 执行,不在本技能重复维护产品字段清单。 以下属性**不需要**参数化,直接使用合理默认值: - 网络:VPC CIDR、VSwitch CIDR @@ -88,22 +82,6 @@ conclusion_schema: - 使用 `!Ref`、`!GetAtt` 等内置函数引用参数和资源属性,避免硬编码 - Outputs 中所有输出变量必须定义 Label -## 常用资源类型 - -- ALIYUN::ECS::VPC: 创建专有网络 -- ALIYUN::ECS::VSwitch: 创建交换机 -- ALIYUN::ECS::SecurityGroup: 创建安全组 -- ALIYUN::ECS::InstanceGroup: 创建 N 个 ECS 实例(通过 `MaxAmount` 指定数量) -- ALIYUN::ECS::RunCommand: 在实例中执行自定义命令 -- ALIYUN::ECS::Invocation: 执行公共命令 - -## 在实例中执行命令 - -**不要使用 UserData + WaitCondition**。根据场景选择: - -- **自定义命令** → `ALIYUN::ECS::RunCommand` + `CommandContent` -- **公共命令** → `ALIYUN::ECS::Invocation` + `CommandName` - ## 资源和文档搜索 - 不确定的资源属性或 Schema → aliyun_api(product="ros", action="GetResourceType", params={"ResourceType": "<类型>"}) diff --git a/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py b/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py index 0d80c87c..41a8f952 100644 --- a/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py +++ b/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py @@ -600,6 +600,37 @@ async def fake_continue(user_input=None, **kwargs): } +@pytest.mark.asyncio +async def test_resume_candidate_selection_extracts_index_from_structured_json(tmp_path): + runner = _build_runner(tmp_path) + runner.state_machine.current_step.ui_mode = "candidate_selection" + runner._waiting_input_options_by_step["s1"] = [ + {"name": "方案A", "candidate_index": 0}, + {"name": "方案B", "candidate_index": 1}, + ] + + async def fake_continue(user_input=None, **kwargs): + assert kwargs == {"resume_waiting_step": True} + if False: + yield + + runner._continue_from_current = fake_continue + user_input = json.dumps( + { + "selected_candidate_index": 1, + "parameter_overrides": {"InstanceType": "ecs.g7.large"}, + }, + ensure_ascii=False, + ) + + events = [event async for event in runner.resume(user_input)] + + received = next(event for event in events if isinstance(event, PipelineEvent)) + assert received.type == PipelineEventType.USER_INPUT_RECEIVED + assert received.data["selected_index"] == 1 + assert received.data["selected_option"] == {"name": "方案B", "candidate_index": 1} + + @pytest.mark.asyncio async def test_resume_candidate_selection_uses_restored_context_options(tmp_path): runner = _build_runner(tmp_path) diff --git a/tests/pipeline/engine/test_ui_contract.py b/tests/pipeline/engine/test_ui_contract.py index 2a8344c9..66547906 100644 --- a/tests/pipeline/engine/test_ui_contract.py +++ b/tests/pipeline/engine/test_ui_contract.py @@ -22,11 +22,46 @@ def test_encode_selected_candidate_returns_json_string(): assert payload == {"selected_candidate_name": "Same", "selected_candidate_index": 1} +def test_encode_selected_candidate_can_include_parameter_overrides(): + payload = json.loads(encode_selected_candidate("Same", 1, {"InstanceType": "ecs.g7.large"})) + assert payload == { + "selected_candidate_name": "Same", + "selected_candidate_index": 1, + "parameter_overrides": {"InstanceType": "ecs.g7.large"}, + } + + def test_parse_selected_candidate_accepts_structured_json_string(): parsed = parse_selected_candidate('{"selected_candidate_name": "Same", "selected_candidate_index": 1}') assert parsed is not None assert parsed.selected_candidate_name == "Same" assert parsed.selected_candidate_index == 1 + assert parsed.parameter_overrides == {} + + +def test_parse_selected_candidate_accepts_parameter_overrides(): + parsed = parse_selected_candidate( + '{"selected_candidate_name": "Same", "selected_candidate_index": 1, ' + '"parameter_overrides": {"InstanceType": "ecs.g7.large", "Optional": null}}' + ) + assert parsed is not None + assert parsed.selected_candidate_name == "Same" + assert parsed.selected_candidate_index == 1 + assert parsed.parameter_overrides == {"InstanceType": "ecs.g7.large"} + + +def test_parse_selected_candidate_accepts_parameters_alias_for_a2a_payloads(): + parsed = parse_selected_candidate('{"selected_candidate_index": 1, "parameters": {"ZoneId": "cn-hangzhou-k"}}') + assert parsed is not None + assert parsed.selected_candidate_index == 1 + assert parsed.parameter_overrides == {"ZoneId": "cn-hangzhou-k"} + + +def test_parse_selected_candidate_rejects_invalid_parameter_overrides(): + parsed = parse_selected_candidate( + '{"selected_candidate_name": "Same", "selected_candidate_index": 1, "parameter_overrides": "bad"}' + ) + assert parsed is None def test_parse_selected_candidate_accepts_legacy_plain_name(): @@ -34,6 +69,7 @@ def test_parse_selected_candidate_accepts_legacy_plain_name(): assert parsed is not None assert parsed.selected_candidate_name == "Same" assert parsed.selected_candidate_index is None + assert parsed.parameter_overrides == {} def test_parse_selected_candidate_extracts_zero_based_index_from_natural_language_choice(): diff --git a/tests/pipeline/selling/skills/test_iac_aliyun_cost_skill.py b/tests/pipeline/selling/skills/test_iac_aliyun_cost_skill.py index baf58fcc..a773fc09 100644 --- a/tests/pipeline/selling/skills/test_iac_aliyun_cost_skill.py +++ b/tests/pipeline/selling/skills/test_iac_aliyun_cost_skill.py @@ -9,6 +9,7 @@ ) SKILL_MD = SKILL_DIR / "SKILL.md" EVALS_JSON = SKILL_DIR / "evals.json" +COST_PROMPT_MD = SKILL_DIR.parents[1] / "prompts" / "cost_estimating.md" def _direct_references_dir_or_skip() -> Path: @@ -55,6 +56,20 @@ def test_description_mentions_cost(self): fm = _parse_frontmatter(content) assert "GetTemplateEstimateCost" in fm["description"] or "费用" in fm["description"] + def test_conclusion_schema_carries_deployment_parameters(self): + content = SKILL_MD.read_text(encoding="utf-8") + fm = _parse_frontmatter(content) + schema = fm["conclusion_schema"] + assert "deployment_parameters" in schema["required"] + assert schema["properties"]["deployment_parameters"]["type"] == "object" + + def test_conclusion_schema_can_report_missing_deployment_parameters(self): + content = SKILL_MD.read_text(encoding="utf-8") + fm = _parse_frontmatter(content) + schema = fm["conclusion_schema"] + assert "missing_deployment_parameters" in schema["properties"] + assert schema["properties"]["missing_deployment_parameters"]["type"] == "array" + class TestSkillContentRosOnly: @pytest.fixture() @@ -91,8 +106,56 @@ def test_modified_template_retry_limit_is_seven(self, body): def test_validate_template_policy_is_not_repeated(self, body): assert body.count("只有在修复或改写模板后") == 1 - def test_contains_parameter_flattening(self, body): - assert "Parameters.1.ParameterKey" in body or "ParameterKey" in body + def test_uses_parameters_dictionary_auto_expansion(self, body): + assert '"Parameters": {' in body + assert "直接传字典格式" in body + assert "工具会自动展开" in body + assert "Parameters.1.ParameterKey" not in body + + def test_outputs_pricing_parameters_for_deployment(self, body): + assert "deployment_parameters" in body + assert "传递给 deploying" in body + assert "写入模板 Parameters 的 `Default`" not in body + assert "沉淀参数默认值" not in body + + def test_contains_parameter_recommendation_flow(self, body): + assert "Pricing Parameter Set" in body + assert "Preview-Validated Pricing Parameter Set" in body + assert "references/template-parameter-recommendation.md" in body + assert "GetTemplateParameterConstraints" in body + assert "PreviewStack" in body + assert "AllowedValues" in body + assert "不得编造" in body + assert "外部输入" in body + assert "不执行 `PreviewStack`" not in body + assert "写回模板的 Default 保持一致" not in body + + def test_existing_resource_parameters_can_use_api_candidates(self, body): + assert "VpcId、VSwitchId、SecurityGroupId、KeyPairName" in body + assert "API 返回候选不是编造" in body + assert "先查询约束或只读资源候选" in body + assert "不要仅因参数名是 VpcId" in body + + def test_preview_stack_uses_aliyun_api_not_ros_stack(self, body): + assert 'aliyun_api(product="ros", action="PreviewStack")' in body + assert "不要使用 `ros_stack` 执行 `PreviewStack`" in body + + def test_parameter_recommendation_precedes_initial_pricing(self, body): + assert "先直接询价" not in body + assert "首次询价前" in body + assert "形成 Preview-Validated Pricing Parameter Set" in body + + def test_preserves_preview_parameters_when_pricing_fails(self, body): + assert "PreviewStack 成功但询价失败" in body + assert "不要丢弃 Preview-Validated Pricing Parameter Set" in body + assert "询价失败或外部输入缺失时填 `{}`" not in body + + def test_preview_stack_is_not_hard_gate_for_pricing(self, body): + assert "PreviewStack 不是硬门禁" in body + assert "完整部署参数" in body + assert "GetTemplateEstimateCost" in body + assert "missing_deployment_parameters" in body + assert "选择阶段" in body and "parameter_overrides" in body def test_contains_template_url(self, body): assert "TemplateURL" in body @@ -111,12 +174,15 @@ def test_no_doc_search_recommendation(self, body): if "aliyun_doc_search" in line: assert "不要" in line or "不" in line or "禁" in line - def test_contains_resource_types(self, body): - assert "ALIYUN::ECS::VPC" in body - assert "ALIYUN::ECS::InstanceGroup" in body + def test_does_not_inline_common_resource_catalog(self, body): + assert "### 常用资源类型" not in body + assert "ALIYUN::ECS::VPC — 专有网络" not in body + assert "ALIYUN::ECS::InstanceGroup — ECS 实例" not in body - def test_contains_parameterization_rules(self, body): - assert "参数化" in body + def test_parameterization_details_stay_in_references(self, body): + assert "### 参数化规则" not in body + assert "| ECS | ZoneId, InstanceType" not in body + assert "references/template-parameters.md" in body def test_contains_error_handling(self, body): assert "失败" in body @@ -193,6 +259,26 @@ def test_skill_content_matches_file(self): assert loaded.skills["iac-aliyun-cost"] == expected +class TestCostPrompt: + def test_prompt_is_not_duplicate_output_reference(self): + body = COST_PROMPT_MD.read_text(encoding="utf-8") + assert "Preview-Validated Pricing Parameter Set" in body + assert "deployment_parameters" not in body + assert "询价失败但 PreviewStack 已成功" not in body + assert "字段为字符串" not in body + + def test_prompt_names_preview_stack_tool_contract(self): + body = COST_PROMPT_MD.read_text(encoding="utf-8") + assert 'aliyun_api(product="ros", action="PreviewStack")' in body + assert "不要使用 `ros_stack` 执行 `PreviewStack`" in body + + def test_prompt_treats_preview_stack_as_soft_gate(self): + body = COST_PROMPT_MD.read_text(encoding="utf-8") + assert "优先通过" in body + assert "不是硬门禁" in body + assert "参数缺口" in body + + class TestEvalsJson: def test_evals_file_exists(self): assert EVALS_JSON.exists() @@ -201,6 +287,42 @@ def test_valid_json(self): data = json.loads(EVALS_JSON.read_text(encoding="utf-8")) assert isinstance(data, dict) + def test_evals_follow_parameter_dictionary_contract(self): + text = EVALS_JSON.read_text(encoding="utf-8") + assert "Parameters..ParameterKey" not in text + assert "Parameters.1.ParameterKey" not in text + assert "deployment_parameters" in text + + def test_evals_do_not_require_validation_before_initial_pricing(self): + text = EVALS_JSON.read_text(encoding="utf-8") + assert "先校验" not in text + + def test_evals_keep_preview_parameters_on_pricing_failure(self): + text = EVALS_JSON.read_text(encoding="utf-8") + assert "PreviewStack 成功但询价失败" in text + assert "不丢弃" in text + + def test_evals_assert_preview_stack_api_tool_contract(self): + data = json.loads(EVALS_JSON.read_text(encoding="utf-8")) + checks = "\n".join(assertion["check"] for ev in data["evals"] for assertion in ev["assertions"]) + assert 'aliyun_api(product="ros", action="PreviewStack")' in checks + assert "不使用 ros_stack" in checks + + def test_evals_cover_existing_vpc_parameter_recommendation(self): + data = json.loads(EVALS_JSON.read_text(encoding="utf-8")) + eval_text = json.dumps(data, ensure_ascii=False) + assert "existing-vpc-vswitch-cost" in eval_text + assert "ALIYUN::ECS::VPC::VPCId" in eval_text + assert "VpcId" in eval_text + assert "API 返回候选不是编造" in eval_text + + def test_evals_cover_preview_stack_soft_gate(self): + data = json.loads(EVALS_JSON.read_text(encoding="utf-8")) + eval_text = json.dumps(data, ensure_ascii=False) + assert "preview-soft-gate-partial-pricing" in eval_text + assert "PreviewStack 不是硬门禁" in eval_text + assert "missing_deployment_parameters" in eval_text + def test_has_required_fields(self): data = json.loads(EVALS_JSON.read_text(encoding="utf-8")) assert data["skill_name"] == "iac-aliyun-cost" diff --git a/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py b/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py index 79c6425c..ef327b47 100644 --- a/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py +++ b/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py @@ -75,6 +75,39 @@ def test_contains_ros_stack(self, body): def test_contains_availability_query(self, body): assert "可用性查询" in body + def test_deploying_uses_parameters_without_preview_recommendation(self, body): + assert "部署参数装配" in body + assert "selected_plan.effective_deployment_parameters" in body + assert "CreateStack" in body + assert "GetTemplateParameterConstraints" not in body + assert "PreviewStack" not in body + assert "Preview-Validated Parameter Set" not in body + assert "参数推荐" not in body + + def test_prefers_cost_deployment_parameters(self, body): + assert "selected_plan.selected_candidate_result.cost.deployment_parameters" in body + assert "按以下优先级" in body + assert "前序成本步骤沉淀的 Default" not in body + + def test_prefers_effective_deployment_parameters(self, body): + assert "selected_plan.effective_deployment_parameters" in body + assert "最终部署参数集" in body + assert "GetTemplateEstimateCost" not in body + + def test_availability_conflict_prefers_non_user_parameters_first(self, body): + assert "优先调整非用户指定参数" in body + assert "仍无法成功创建资源栈" in body + assert "才可调整用户指定参数" in body + + def test_skill_omits_discussion_process_terms(self, body): + forbidden = ["A2A", "前端", "客户端", "方案 A", "方案 B", "策略 A", "策略 B", "讨论"] + for phrase in forbidden: + assert phrase not in body + + def test_does_not_mention_stack_instances(self, body): + assert "CreateStackInstances" not in body + assert "UpdateStackInstances" not in body + def test_contains_template_validation(self, body): assert "ValidateTemplate" in body assert "模板校验" in body @@ -113,13 +146,50 @@ def test_pipeline_confirmed_deploy_does_not_ask_again(self, body): assert "不要再次请求用户确认" in body assert "不得用 status: cancelled 表示等待用户确认" in body + def test_delete_requires_explicit_delete_confirmation(self, body): + assert "删除请求本身不等于删除确认" in body + assert "`delete_confirmed: true`" in body + assert "确认删除" in body + assert "未收到明确删除确认前,不得调用 `ros_stack` 的 `DeleteStack`" in body + class TestDeployingPrompt: def test_pipeline_confirmed_deploy_is_direct_execution(self): body = DEPLOYING_PROMPT_MD.read_text(encoding="utf-8") assert "不要再次询问是否确认部署" in body - assert "不得用 status: cancelled 表示等待用户确认" in body - assert "只有用户明确取消部署时" in body + assert "不得用 status: cancelled 表示等待用户确认" not in body + assert "只有用户明确取消部署时" not in body + + def test_prompt_defers_parameter_priority_to_skill(self): + body = DEPLOYING_PROMPT_MD.read_text(encoding="utf-8") + assert "selected_plan.selected_candidate_result.cost.deployment_parameters" not in body + assert "部署参数按以下优先级装配" not in body + assert "部署参数装配规则见技能" in body + + def test_prompt_keeps_no_repricing_without_parameter_priority_duplication(self): + body = DEPLOYING_PROMPT_MD.read_text(encoding="utf-8") + assert "部署步骤不计算费用" in body + assert "selected_plan.effective_deployment_parameters" not in body + assert "GetTemplateEstimateCost" not in body + + def test_prompt_does_not_repeat_parameter_adjustment_rules(self): + body = DEPLOYING_PROMPT_MD.read_text(encoding="utf-8") + assert "优先调整非用户指定参数" not in body + assert "仍无法成功创建资源栈" not in body + assert "才可调整用户指定参数" not in body + assert "可用区不可用 → 自动更换可用区重试" not in body + + def test_prompt_omits_discussion_process_terms(self): + body = DEPLOYING_PROMPT_MD.read_text(encoding="utf-8") + forbidden = ["A2A", "前端", "客户端", "方案 A", "方案 B", "策略 A", "策略 B", "讨论"] + for phrase in forbidden: + assert phrase not in body + + def test_prompt_delete_requires_explicit_delete_confirmation(self): + body = DEPLOYING_PROMPT_MD.read_text(encoding="utf-8") + assert "删除请求本身不等于删除确认" in body + assert "`delete_confirmed: true`" in body + assert "未收到明确删除确认前,不得调用 `ros_stack` 的 `DeleteStack`" in body class TestSkillDiscovery: @@ -195,3 +265,18 @@ def test_eval_names_are_unique(self): data = json.loads(EVALS_JSON.read_text(encoding="utf-8")) names = [ev["name"] for ev in data["evals"]] assert len(names) == len(set(names)) + + def test_delete_evals_split_confirmation_and_execution(self): + data = json.loads(EVALS_JSON.read_text(encoding="utf-8")) + evals_by_name = {ev["name"]: ev for ev in data["evals"]} + + confirmation_eval = evals_by_name["delete-stack-confirmation"] + confirmation_assertions = {assertion["name"] for assertion in confirmation_eval["assertions"]} + assert "user_confirmation" in confirmation_assertions + assert "uses_delete_stack" not in confirmation_assertions + assert "no_delete_without_confirmation" in confirmation_assertions + + confirmed_eval = evals_by_name["delete-stack-confirmed"] + confirmed_assertions = {assertion["name"] for assertion in confirmed_eval["assertions"]} + assert "确认" in confirmed_eval["prompt"] + assert "uses_delete_stack" in confirmed_assertions diff --git a/tests/pipeline/selling/skills/test_template_generating_skill.py b/tests/pipeline/selling/skills/test_template_generating_skill.py index b1e5ec2b..a184ef1a 100644 --- a/tests/pipeline/selling/skills/test_template_generating_skill.py +++ b/tests/pipeline/selling/skills/test_template_generating_skill.py @@ -67,15 +67,29 @@ def test_no_terraform_references(self, body): def test_contains_ros_template_format(self, body): assert "ROSTemplateFormatVersion" in body or "ROS" in body - def test_contains_parameterization_rules(self, body): - assert "参数化规则" in body + def test_parameterization_guidance_points_to_references_without_inline_table(self, body): + assert "库存相关属性" in body + assert "references/cloud-products/" in body + assert "| ECS | ZoneId, InstanceType" not in body def test_contains_validation_step(self, body): assert "ValidateTemplate" in body - def test_contains_resource_types(self, body): - assert "ALIYUN::ECS::VPC" in body - assert "ALIYUN::ECS::InstanceGroup" in body + def test_must_read_ros_template_reference_before_generation(self, body): + assert "必须" in body + assert "references/ros-template.md" in body + assert "未阅读不得生成模板" in body + + def test_does_not_inline_common_resource_catalog(self, body): + assert "## 常用资源类型" not in body + assert "ALIYUN::ECS::VPC: 创建专有网络" not in body + assert "ALIYUN::ECS::InstanceGroup: 创建 N 个 ECS 实例" not in body + assert "references/ros-template.md" in body + + def test_run_command_details_stay_in_reference(self, body): + assert "## 在实例中执行命令" not in body + assert "ALIYUN::ECS::RunCommand + `CommandContent`" not in body + assert "references/ros-template.md" in body def test_no_deploy_flow(self, body): assert "CreateStack" not in body diff --git a/tests/pipeline/selling/test_deploying_hook.py b/tests/pipeline/selling/test_deploying_hook.py index 1cff2dd9..fc716b49 100644 --- a/tests/pipeline/selling/test_deploying_hook.py +++ b/tests/pipeline/selling/test_deploying_hook.py @@ -35,6 +35,64 @@ def test_normalize_selected_plan_adds_resolution_metadata(): assert normalized["selection"]["selected_candidate_index"] == 0 +def test_normalize_selected_plan_preserves_cost_deployment_parameters(): + evaluated_candidates = [ + { + "candidate": {"name": "WithParams", "output_path": "templates/a.yml"}, + "failed": False, + "cost": {"deployment_parameters": {"ZoneId": "cn-hangzhou-k", "InstanceType": "ecs.g7.large"}}, + } + ] + selected_plan = {"user_input": encode_selected_candidate("WithParams", 0), "options": []} + + normalized = normalize_selected_plan(selected_plan, evaluated_candidates) + + assert normalized["selection_valid"] is True + assert normalized["selected_candidate_result"]["cost"]["deployment_parameters"] == { + "ZoneId": "cn-hangzhou-k", + "InstanceType": "ecs.g7.large", + } + + +def test_normalize_selected_plan_applies_user_parameter_overrides(): + evaluated_candidates = [ + { + "candidate": {"name": "WithParams", "output_path": "templates/a.yml"}, + "failed": False, + "cost": { + "deployment_parameters": { + "ZoneId": "cn-hangzhou-k", + "InstanceType": "ecs.g7.large", + "SystemDiskCategory": "cloud_essd", + } + }, + } + ] + selected_plan = { + "user_input": encode_selected_candidate( + "WithParams", + 0, + {"InstanceType": "ecs.c7.large", "ImageId": "centos_stream_9_x64_20G_alibase_20260414.vhd"}, + ), + "options": [], + } + + normalized = normalize_selected_plan(selected_plan, evaluated_candidates) + + assert normalized["selection_valid"] is True + assert normalized["parameter_overrides"] == { + "InstanceType": "ecs.c7.large", + "ImageId": "centos_stream_9_x64_20G_alibase_20260414.vhd", + } + assert normalized["effective_deployment_parameters"] == { + "ZoneId": "cn-hangzhou-k", + "InstanceType": "ecs.c7.large", + "SystemDiskCategory": "cloud_essd", + "ImageId": "centos_stream_9_x64_20G_alibase_20260414.vhd", + } + assert normalized["cost_estimate_parameter_overridden"] is True + + def test_normalize_selected_plan_resolves_natural_language_zero_based_choice(): selected_plan = {"user_input": "我选择方案0", "options": []} normalized = normalize_selected_plan(selected_plan, _evaluated_candidates()) diff --git a/tests/pipeline/selling/test_terminal_ui_contract.py b/tests/pipeline/selling/test_terminal_ui_contract.py index 51ae5823..198c44b7 100644 --- a/tests/pipeline/selling/test_terminal_ui_contract.py +++ b/tests/pipeline/selling/test_terminal_ui_contract.py @@ -18,12 +18,33 @@ def test_confirm_options_schema_requires_candidate_index(): assert option_schema["properties"]["candidate_index"]["type"] == "integer" +def test_confirm_schema_accepts_parameter_overrides(): + loaded = load_pipeline_dir(_selling_pipeline_dir()) + confirm = next(step for step in loaded.steps if step.step_id == "confirm_and_select") + schema = confirm.conclusion_schema + assert schema is not None + + assert "parameter_overrides" in schema["properties"] + assert schema["properties"]["parameter_overrides"]["type"] == "object" + + def test_confirm_prompt_tells_model_to_output_candidate_index(): prompt = (_selling_pipeline_dir() / "prompts" / "confirm_and_select.md").read_text(encoding="utf-8") assert "`options[].candidate_index`" in prompt +def test_confirm_prompt_tells_model_to_preserve_parameter_overrides(): + prompt = (_selling_pipeline_dir() / "prompts" / "confirm_and_select.md").read_text(encoding="utf-8") + + assert "`parameter_overrides`" in prompt + assert "用户选择方案时传入" in prompt + assert "结构化 JSON" in prompt + forbidden = ["A2A", "前端", "客户端", "方案 A", "方案 B", "策略 A", "策略 B", "讨论"] + for phrase in forbidden: + assert phrase not in prompt + + def test_selling_steps_do_not_expose_static_rollback_rules(): loaded = load_pipeline_dir(_selling_pipeline_dir()) From ab87483f7a3dc0a2ba5d78dfe5aa074724450192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 22 Jun 2026 20:20:56 +0800 Subject: [PATCH 14/59] feat: add pipeline surface prompt overrides --- src/iac_code/a2a/pipeline_executor.py | 1 + src/iac_code/pipeline/__init__.py | 2 + src/iac_code/pipeline/engine/loader.py | 63 +++++++++++++++-- .../pipeline/engine/pipeline_runner.py | 6 ++ src/iac_code/pipeline/engine/step_executor.py | 12 ++-- src/iac_code/pipeline/engine/step_spec.py | 21 ++++++ .../pipeline/engine/sub_pipeline_executor.py | 4 ++ src/iac_code/pipeline/selling/pipeline.yaml | 4 ++ .../selling/prompts/confirm_and_select.a2a.md | 63 +++++++++++++++++ .../selling/prompts/confirm_and_select.md | 61 ++++++++++------- tests/a2a/test_pipeline_executor.py | 3 + tests/pipeline/engine/test_loader.py | 28 ++++++++ tests/pipeline/engine/test_step_executor.py | 67 ++++++++++++++++++- .../selling/test_terminal_ui_contract.py | 39 +++++++++++ 14 files changed, 338 insertions(+), 36 deletions(-) create mode 100644 src/iac_code/pipeline/selling/prompts/confirm_and_select.a2a.md diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index 78a2cf48..0c4f120a 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -647,6 +647,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: diff --git a/src/iac_code/pipeline/__init__.py b/src/iac_code/pipeline/__init__.py index 024628eb..e34a3533 100644 --- a/src/iac_code/pipeline/__init__.py +++ b/src/iac_code/pipeline/__init__.py @@ -33,6 +33,7 @@ def create_pipeline( memory_content_getter: Callable[[], str] | None = None, auto_trigger_skills: list[Any] | None = None, resume_from_sidecar: bool = False, + surface: str = "repl", ) -> PipelineRunner: """Factory: create a pipeline runner by name. @@ -60,6 +61,7 @@ def create_pipeline( memory_content_getter=memory_content_getter, auto_trigger_skills=auto_trigger_skills, resume_from_sidecar=resume_from_sidecar, + surface=surface, ) diff --git a/src/iac_code/pipeline/engine/loader.py b/src/iac_code/pipeline/engine/loader.py index 3ec2a2fc..c1cc59c1 100644 --- a/src/iac_code/pipeline/engine/loader.py +++ b/src/iac_code/pipeline/engine/loader.py @@ -20,6 +20,7 @@ LoadedPipeline, OnCompletePolicy, StepSpec, + StepSurfaceOverride, SubPipelineSpec, ) @@ -212,6 +213,7 @@ def _parse_steps(raw_steps: list[dict]) -> list[StepSpec]: else: step_sections = None + step_id = raw.get("id", "?") steps.append( StepSpec( step_id=raw["id"], @@ -238,13 +240,55 @@ def _parse_steps(raw_steps: list[dict]) -> list[StepSpec]: ), completion_guards=raw.get("completion_guards", []), description=raw.get("description", ""), - exit_condition=_parse_exit_condition(raw.get("exit_condition"), raw.get("id", "?")), - a2a_artifacts=_parse_a2a_artifacts(raw.get("a2a_artifacts"), raw.get("id", "?")), + exit_condition=_parse_exit_condition(raw.get("exit_condition"), step_id), + a2a_artifacts=_parse_a2a_artifacts(raw.get("a2a_artifacts"), step_id), + surface_overrides=_parse_surface_overrides(raw.get("surface_overrides"), step_id), ) ) return steps +def _parse_surface_overrides(raw: object, step_id: str) -> dict[str, StepSurfaceOverride]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError(f"Step '{step_id}': surface_overrides must be a mapping, got {raw!r}") + + overrides: dict[str, StepSurfaceOverride] = {} + for surface, raw_override in raw.items(): + if not isinstance(surface, str) or not surface: + raise ValueError(f"Step '{step_id}': surface_overrides keys must be non-empty strings, got {surface!r}") + if not isinstance(raw_override, dict): + raise ValueError(f"Step '{step_id}': surface_overrides.{surface} must be a mapping, got {raw_override!r}") + override = cast(dict[str, Any], raw_override) + unsupported = set(override) - {"prompt", "inject_tools"} + if unsupported: + supported = "inject_tools, prompt" + unknown = ", ".join(sorted(unsupported)) + raise ValueError( + f"Step '{step_id}': surface_overrides.{surface} contains unsupported key(s): " + f"{unknown}; supported: {supported}" + ) + + prompt = override.get("prompt") + if prompt is not None and not isinstance(prompt, str): + raise ValueError(f"Step '{step_id}': surface_overrides.{surface}.prompt must be a string") + + inject_tools = override.get("inject_tools") + if inject_tools is not None: + if not isinstance(inject_tools, list) or not all(isinstance(name, str) for name in inject_tools): + raise ValueError( + f"Step '{step_id}': surface_overrides.{surface}.inject_tools must be a list of strings" + ) + inject_tools = cast(list[str], inject_tools) + + overrides[surface] = StepSurfaceOverride( + prompt_file=prompt, + inject_tools=list(inject_tools) if inject_tools is not None else None, + ) + return overrides + + def _parse_a2a_artifacts(raw: object, step_id: str) -> list[A2AArtifactSpec]: if raw is None: return [] @@ -344,11 +388,16 @@ def _load_module_from_file(path: Path, module_name: str) -> ModuleType: def _validate_prompts_exist(steps: list[StepSpec], pipeline_dir: Path) -> None: for step in steps: - if not step.prompt_file: - continue - prompt_path = pipeline_dir / step.prompt_file - if not prompt_path.exists(): - raise FileNotFoundError(f"Prompt file not found: {prompt_path}") + prompt_files = [step.prompt_file] + prompt_files.extend( + override.prompt_file for override in step.surface_overrides.values() if override.prompt_file is not None + ) + for prompt_file in prompt_files: + if not prompt_file: + continue + prompt_path = pipeline_dir / prompt_file + if not prompt_path.exists(): + raise FileNotFoundError(f"Prompt file not found: {prompt_path}") def _discover_pipeline_tools(pipeline_dir: Path) -> dict[str, type]: diff --git a/src/iac_code/pipeline/engine/pipeline_runner.py b/src/iac_code/pipeline/engine/pipeline_runner.py index e2959f8e..a93a4f2a 100644 --- a/src/iac_code/pipeline/engine/pipeline_runner.py +++ b/src/iac_code/pipeline/engine/pipeline_runner.py @@ -314,6 +314,7 @@ def __init__( memory_content_getter: Callable[[], str] | None = None, auto_trigger_skills: list[Any] | None = None, resume_from_sidecar: bool = False, + surface: str = "repl", ) -> None: self._session_storage = session_storage self._session_id = session_id @@ -321,6 +322,7 @@ def __init__( self._permission_context_getter = permission_context_getter self._memory_content_getter = memory_content_getter self._auto_trigger_skills = auto_trigger_skills or [] + self._surface = surface self._pipeline_dir = pipeline_dir self._loaded: LoadedPipeline = load_pipeline_dir(pipeline_dir) @@ -383,6 +385,7 @@ def __init__( permission_context_getter=self._permission_context_getter, memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, + surface=self._surface, ) self._apply_telemetry_correlation(self._step_executor) @@ -1622,6 +1625,7 @@ def _restored_parallel_prompt_contexts(self, current_step: StepSpec) -> list[Pro permission_context_getter=self._permission_context_getter, memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, + surface=self._surface, ) self._apply_telemetry_correlation(sub_context_executor) sub_context_dependencies = sub_context_executor._sub_context_dependencies(sub_spec) @@ -1664,6 +1668,7 @@ def _restored_parallel_prompt_contexts(self, current_step: StepSpec) -> list[Pro permission_context_getter=self._permission_context_getter, memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, + surface=self._surface, ) self._apply_telemetry_correlation(step_executor) agent_context = step_executor.build_agent_loop_context( @@ -3339,6 +3344,7 @@ async def _execute_parallel_sub_pipeline( permission_context_getter=self._permission_context_getter, memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, + surface=self._surface, ) for _ in candidates ] diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index d6685dc9..24d4ed06 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -67,6 +67,7 @@ def __init__( permission_context_getter: Callable[[], Any] | None = None, memory_content_getter: Callable[[], str] | None = None, auto_trigger_skills: list[Any] | None = None, + surface: str = "repl", ) -> None: self._provider_manager = provider_manager self._base_tool_registry = base_tool_registry @@ -78,6 +79,7 @@ def __init__( self._permission_context_getter = permission_context_getter self._memory_content_getter = memory_content_getter self._auto_trigger_skills = auto_trigger_skills or [] + self._surface = surface self._current_agent_loop = None pipeline_name = getattr(pipeline, "name", "") if not isinstance(pipeline_name, str): @@ -406,8 +408,9 @@ def _build_full_system_prompt(self, step: StepSpec, context: PipelineContext) -> memory_content=memory_content, ) - prompt_path = self._pipeline_dir / step.prompt_file - step_prompt = prompt_path.read_text(encoding="utf-8") if step.prompt_file else "" + prompt_file = step.prompt_file_for_surface(self._surface) + prompt_path = self._pipeline_dir / prompt_file + step_prompt = prompt_path.read_text(encoding="utf-8") if prompt_file else "" rendered_step_prompt = render_prompt(step_prompt, context, step.context_fields) skill_content = "" @@ -664,8 +667,9 @@ def _build_step_tools( ) ) - if step.inject_tools: - self._register_injectable_tools(registry, step.inject_tools, guard_state) + inject_tools = step.inject_tools_for_surface(self._surface) + if inject_tools: + self._register_injectable_tools(registry, inject_tools, guard_state) return registry diff --git a/src/iac_code/pipeline/engine/step_spec.py b/src/iac_code/pipeline/engine/step_spec.py index 74902987..85ded9ff 100644 --- a/src/iac_code/pipeline/engine/step_spec.py +++ b/src/iac_code/pipeline/engine/step_spec.py @@ -26,6 +26,14 @@ class A2AArtifactSpec: media_type: str = "auto" +@dataclass(frozen=True) +class StepSurfaceOverride: + """Per-surface overrides for selected step fields.""" + + prompt_file: str | None = None + inject_tools: list[str] | None = None + + @dataclass class HandoffContextConfig: """Context fields to include when handing off from pipeline to normal chat.""" @@ -73,6 +81,19 @@ class StepSpec: description: str = "" exit_condition: dict | None = None a2a_artifacts: list[A2AArtifactSpec] = field(default_factory=list) + surface_overrides: dict[str, StepSurfaceOverride] = field(default_factory=dict) + + def prompt_file_for_surface(self, surface: str | None) -> str: + override = self.surface_overrides.get(surface or "") + if override is not None and override.prompt_file is not None: + return override.prompt_file + return self.prompt_file + + def inject_tools_for_surface(self, surface: str | None) -> list[str]: + override = self.surface_overrides.get(surface or "") + if override is not None and override.inject_tools is not None: + return list(override.inject_tools) + return list(self.inject_tools) @dataclass diff --git a/src/iac_code/pipeline/engine/sub_pipeline_executor.py b/src/iac_code/pipeline/engine/sub_pipeline_executor.py index b7173e36..bbeab523 100644 --- a/src/iac_code/pipeline/engine/sub_pipeline_executor.py +++ b/src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -72,6 +72,7 @@ def __init__( permission_context_getter: Callable[[], Any] | None = None, memory_content_getter: Callable[[], str] | None = None, auto_trigger_skills: list[Any] | None = None, + surface: str = "repl", ) -> None: self._provider_manager = provider_manager self._base_tool_registry = base_tool_registry @@ -83,6 +84,7 @@ def __init__( self._permission_context_getter = permission_context_getter self._memory_content_getter = memory_content_getter self._auto_trigger_skills = auto_trigger_skills or [] + self._surface = surface self._active_step_executor = None self._telemetry_correlation: dict[str, str] = {} pipeline_name = getattr(pipeline, "name", "") @@ -160,6 +162,7 @@ async def execute( permission_context_getter=self._permission_context_getter, memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, + surface=self._surface, ) self._apply_telemetry_correlation(step_executor) @@ -333,6 +336,7 @@ def _make_step_executor(self) -> StepExecutor: permission_context_getter=self._permission_context_getter, memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, + surface=self._surface, ) self._apply_telemetry_correlation(executor) return executor diff --git a/src/iac_code/pipeline/selling/pipeline.yaml b/src/iac_code/pipeline/selling/pipeline.yaml index 05685803..86509ff0 100644 --- a/src/iac_code/pipeline/selling/pipeline.yaml +++ b/src/iac_code/pipeline/selling/pipeline.yaml @@ -201,6 +201,10 @@ steps: auto_advance: false ui_mode: candidate_selection inject_tools: [show_architecture_diagram, show_candidate_detail] + surface_overrides: + a2a: + prompt: prompts/confirm_and_select.a2a.md + inject_tools: [] tools: include: [read_file] exclude: [] diff --git a/src/iac_code/pipeline/selling/prompts/confirm_and_select.a2a.md b/src/iac_code/pipeline/selling/prompts/confirm_and_select.a2a.md new file mode 100644 index 00000000..226378e1 --- /dev/null +++ b/src/iac_code/pipeline/selling/prompts/confirm_and_select.a2a.md @@ -0,0 +1,63 @@ +# 步骤:方案确认与选择 + +你正在执行 AI 售卖流程的方案确认步骤。 + +## 任务 +基于候选方案评估结果生成可选择方案列表,并在用户选择后提交最终选择结果。 + +## 评估结果 +```json +{evaluated_candidates} +``` + +## 首次执行 + +如果当前没有用户选择消息,直接调用 `complete_step` 提交待选择结论,随后流程会等待用户输入。 + +仅包含 `failed` 为 `false` 的方案;失败方案不要加入 `options`。 + +### 待选择结论 + +`complete_step.conclusion.options` 中每个可选方案必须包含: +- `options[].name`:候选方案名称,取 `candidate.name` +- `options[].summary`:候选方案摘要 +- `options[].candidate_index`:候选方案在 `evaluated_candidates` 数组中的 0 基下标 + +`complete_step.conclusion.user_prompt` 必须是展示给用户的选择提示,例如“请选择要部署的方案:”。 + +## 收到用户选择 + +如果当前用户消息是在选择方案(例如包含“选择方案0”、“方案1”、候选方案名称,或表达“选便宜/高可用/已有VPC”等偏好),请直接根据用户输入和上方 `evaluated_candidates` 判断最终选择,并调用 `complete_step` 提交最终结论。 + +如果当前用户消息是结构化 JSON 选择消息,例如: +```json +{ + "selected_candidate_index": 0, + "parameter_overrides": { + "ZoneId": "cn-hangzhou-k", + "InstanceType": "ecs.g7.large" + } +} +``` + +必须按以下规则处理: +- `selected_candidate_index`:按 0 基下标选择对应候选方案 +- `selected_candidate_name`:如果用户提供名称,则按候选方案名称匹配 +- `parameter_overrides`:用户传入的部署参数覆盖字典,必须原样整理为 `parameter_overrides` +- `parameters`:兼容字段,若用户传入 `parameters`,也必须整理为 `parameter_overrides` + +收到用户选择后再次调用 `complete_step` 提交最终结论,结论必须保留 `options`,并额外包含: +- `user_input`:用户本次选择的原始文本 +- `selected_candidate_name`:最终选择的候选方案名称,必须取 `candidate.name` +- `selected_candidate_index`:最终选择的候选方案在 `evaluated_candidates` 数组中的 0 基下标 +- `parameter_overrides`:用户选择方案时传入的部署参数覆盖字典;没有传入时可省略 + +如果用户输入可以明确映射到某个方案编号(例如“方案0”),按 0 基下标选择对应方案。 +如果用户输入匹配某个候选方案名称,选择该方案。 +如果用户用偏好描述选择方案,请根据候选方案摘要、架构特点、成本和用户偏好选择最匹配的方案。 + +## 约束 +- 不要读取项目文件或记忆,所需上下文已在上方提供。 +- 不要在本步骤重新询价。 +- 不要修改模板 Default。 +- 不要把 `parameter_overrides` 写入模板;后续部署步骤会基于最终选择结果处理部署参数。 diff --git a/src/iac_code/pipeline/selling/prompts/confirm_and_select.md b/src/iac_code/pipeline/selling/prompts/confirm_and_select.md index 407521a9..db56acef 100644 --- a/src/iac_code/pipeline/selling/prompts/confirm_and_select.md +++ b/src/iac_code/pipeline/selling/prompts/confirm_and_select.md @@ -3,40 +3,30 @@ 你正在执行 AI 售卖流程的方案确认步骤。 ## 任务 -向用户展示所有候选方案的评估结果,帮助用户选择最终部署方案。 +基于候选方案评估结果生成可选择方案列表,并在用户选择后提交最终选择结果。 ## 评估结果 ```json {evaluated_candidates} ``` -## 展示流程 +## 首次执行 -如果当前用户消息是在选择方案(例如包含“选择方案0”、“方案1”、候选方案名称,或表达“选便宜/高可用/已有VPC”等偏好),不要再次展示所有方案,也不要再次调用展示工具;请直接根据用户输入和上方 `evaluated_candidates` 判断最终选择,并调用 `complete_step` 提交最终结论。 +如果当前没有用户选择消息,按以下流程展示候选方案,并在展示完成后调用 `complete_step` 提交待选择结论,随后流程会等待用户输入。 -如果当前用户消息是结构化 JSON 选择消息,例如: -```json -{ - "selected_candidate_index": 0, - "parameter_overrides": { - "ZoneId": "cn-hangzhou-k", - "InstanceType": "ecs.g7.large" - } -} -``` -如果用户选择方案时传入 `parameter_overrides`(或兼容字段 `parameters`),必须原样整理为 `parameter_overrides` 放入最终结论;不要写入模板 Default,也不要在本步骤重新询价。 +仅展示 `failed` 为 `false` 的方案;失败方案不要调用展示工具,也不要加入 `options`。 -如果当前没有用户选择消息,按以下流程展示候选方案并等待用户选择。 +### 展示候选方案 对每个 `failed` 为 `false` 的方案,依次调用以下两个工具: -### 1. 生成架构图 +#### 1. 生成架构图 调用 `show_architecture_diagram` 工具: - `file_path`:取 `candidate.output_path` - `candidate_name`:取 `candidate.name` - `candidate_index`:该方案在 `evaluated_candidates` 数组中的 0 基下标 -### 2. 展示方案详情 +#### 2. 展示方案详情 调用 `show_candidate_detail` 工具: - `candidate_name`:取 `candidate.name`(必须与架构图的 candidate_name 一致) - `candidate_index`:该方案在 `evaluated_candidates` 数组中的 0 基下标 @@ -47,18 +37,38 @@ - `monthly_cost`:月费用(如 "¥200/月") - `total_monthly_cost`:月度总费用(如 "¥1,234/月") -## 注意事项 - 先为所有方案调用 `show_architecture_diagram`,再为所有方案调用 `show_candidate_detail` - 不要用文字输出对比表格或方案信息 — 所有展示数据通过上述工具传递 -- 失败的方案跳过,不调用工具 -## 输出 -首次展示完成后调用 `complete_step` 提交待选择结论,随后流程会等待用户输入。 +### 待选择结论 `complete_step.conclusion.options` 中每个可选方案必须包含: - `options[].name`:候选方案名称,取 `candidate.name` - `options[].summary`:候选方案摘要 -- `options[].candidate_index`:该方案在 `evaluated_candidates` 数组中的 0 基下标 +- `options[].candidate_index`:候选方案在 `evaluated_candidates` 数组中的 0 基下标 + +`complete_step.conclusion.user_prompt` 必须是展示给用户的选择提示,例如“请选择要部署的方案:”。 + +## 收到用户选择 + +如果当前用户消息是在选择方案(例如包含“选择方案0”、“方案1”、候选方案名称,或表达“选便宜/高可用/已有VPC”等偏好),请直接根据用户输入和上方 `evaluated_candidates` 判断最终选择,并调用 `complete_step` 提交最终结论。 + +如果当前用户消息是结构化 JSON 选择消息,例如: +```json +{ + "selected_candidate_index": 0, + "parameter_overrides": { + "ZoneId": "cn-hangzhou-k", + "InstanceType": "ecs.g7.large" + } +} +``` + +必须按以下规则处理: +- `selected_candidate_index`:按 0 基下标选择对应候选方案 +- `selected_candidate_name`:如果用户提供名称,则按候选方案名称匹配 +- `parameter_overrides`:用户传入的部署参数覆盖字典,必须原样整理为 `parameter_overrides` +- `parameters`:兼容字段,若用户传入 `parameters`,也必须整理为 `parameter_overrides` 收到用户选择后再次调用 `complete_step` 提交最终结论,结论必须保留 `options`,并额外包含: - `user_input`:用户本次选择的原始文本 @@ -70,5 +80,8 @@ 如果用户输入匹配某个候选方案名称,选择该方案。 如果用户用偏好描述选择方案,请根据候选方案摘要、架构特点、成本和用户偏好选择最匹配的方案。 -## 其他 -- 不要读取项目文件或记忆,所需的上下文已在上方提供。 +## 约束 +- 不要读取项目文件或记忆,所需上下文已在上方提供。 +- 不要在本步骤重新询价。 +- 不要修改模板 Default。 +- 不要把 `parameter_overrides` 写入模板;后续部署步骤会基于最终选择结果处理部署参数。 diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index d1acbc2c..738c6303 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -378,8 +378,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"] @@ -401,6 +403,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 diff --git a/tests/pipeline/engine/test_loader.py b/tests/pipeline/engine/test_loader.py index 2f675dd2..41597451 100644 --- a/tests/pipeline/engine/test_loader.py +++ b/tests/pipeline/engine/test_loader.py @@ -478,6 +478,34 @@ def test_inject_tools_default_empty(self, tmp_path): assert loaded.steps[0].inject_tools == [] +class TestSurfaceOverridesParsing: + def test_surface_overrides_parse_prompt_and_inject_tools(self, tmp_path): + yaml_content = dedent("""\ + name: test + context_dependencies: + result: [] + max_rollbacks: 1 + steps: + - id: confirm + conclusion_field: result + forward: null + prompt: prompts/confirm.md + inject_tools: [show_architecture_diagram, show_candidate_detail] + surface_overrides: + a2a: + prompt: prompts/confirm.a2a.md + inject_tools: [] + """) + _write_pipeline(tmp_path, yaml_content, {"confirm.md": "C", "confirm.a2a.md": "A2A"}) + + loaded = load_pipeline_dir(tmp_path) + step = loaded.steps[0] + + assert step.surface_overrides["a2a"].prompt_file == "prompts/confirm.a2a.md" + assert step.surface_overrides["a2a"].inject_tools == [] + assert step.inject_tools == ["show_architecture_diagram", "show_candidate_detail"] + + class TestUiMode: def test_ui_mode_parsed_from_yaml(self, tmp_path): yaml_content = dedent("""\ diff --git a/tests/pipeline/engine/test_step_executor.py b/tests/pipeline/engine/test_step_executor.py index 90db7087..38c3056a 100644 --- a/tests/pipeline/engine/test_step_executor.py +++ b/tests/pipeline/engine/test_step_executor.py @@ -8,7 +8,7 @@ from iac_code.agent.message import Message, ToolResultBlock, ToolUseBlock from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.step_executor import StepExecutor -from iac_code.pipeline.engine.step_spec import IncludeExcludeConfig, LoadedPipeline, StepSpec +from iac_code.pipeline.engine.step_spec import IncludeExcludeConfig, LoadedPipeline, StepSpec, StepSurfaceOverride from iac_code.pipeline.engine.types import StepResult, StepStatus from iac_code.tools.base import ToolContext, ToolRegistry from iac_code.types.stream_events import ( @@ -717,6 +717,38 @@ def test_no_skill_uses_prompt_only(self, tmp_path): assert "# Step Prompt Only" in prompt assert prompt.endswith("# Step Prompt Only") + def test_surface_override_uses_surface_prompt_file(self, tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "confirm.md").write_text("# REPL Prompt", encoding="utf-8") + (tmp_path / "prompts" / "confirm.a2a.md").write_text("# A2A Prompt", encoding="utf-8") + + step = StepSpec( + step_id="confirm_and_select", + conclusion_field="selected_plan", + forward=None, + prompt_file="prompts/confirm.md", + surface_overrides={"a2a": StepSurfaceOverride(prompt_file="prompts/confirm.a2a.md")}, + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"selected_plan": []}, + max_rollbacks=3, + skills={}, + ) + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + surface="a2a", + ) + + prompt = executor._build_full_system_prompt(step, PipelineContext({"selected_plan": []})) + + assert "# A2A Prompt" in prompt + assert "# REPL Prompt" not in prompt + def test_empty_prompt_file_with_skill(self, tmp_path): """When prompt_file is empty string but skill exists, just use skill content.""" step = StepSpec( @@ -1017,6 +1049,39 @@ def test_no_inject_tools_only_has_complete_step(self, tmp_path): assert registry.get("ask_user_question") is None assert registry.get("complete_step") is not None + def test_surface_override_can_disable_injected_tools(self, tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "confirm.md").write_text("Confirm.", encoding="utf-8") + + step = StepSpec( + step_id="confirm_and_select", + conclusion_field="selected_plan", + forward=None, + prompt_file="prompts/confirm.md", + inject_tools=["show_architecture_diagram"], + surface_overrides={"a2a": StepSurfaceOverride(inject_tools=[])}, + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"selected_plan": []}, + max_rollbacks=3, + skills={}, + ) + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + surface="a2a", + ) + + context = PipelineContext({"selected_plan": []}) + registry = executor._build_step_tools(step, context) + + assert registry.get("show_architecture_diagram") is None + assert registry.get("complete_step") is not None + @pytest.mark.asyncio async def test_ask_user_question_continues_same_agent_loop_to_complete_step(self, tmp_path): (tmp_path / "prompts").mkdir(exist_ok=True) diff --git a/tests/pipeline/selling/test_terminal_ui_contract.py b/tests/pipeline/selling/test_terminal_ui_contract.py index 198c44b7..18324f47 100644 --- a/tests/pipeline/selling/test_terminal_ui_contract.py +++ b/tests/pipeline/selling/test_terminal_ui_contract.py @@ -45,6 +45,45 @@ def test_confirm_prompt_tells_model_to_preserve_parameter_overrides(): assert phrase not in prompt +def test_confirm_prompts_share_selection_contract_structure(): + repl_prompt = (_selling_pipeline_dir() / "prompts" / "confirm_and_select.md").read_text(encoding="utf-8") + a2a_prompt = (_selling_pipeline_dir() / "prompts" / "confirm_and_select.a2a.md").read_text(encoding="utf-8") + + shared_fragments = [ + "## 首次执行", + "### 待选择结论", + "`complete_step.conclusion.options`", + "`complete_step.conclusion.user_prompt`", + "## 收到用户选择", + '"selected_candidate_index": 0', + "`parameter_overrides`", + "`parameters`", + "## 约束", + "不要在本步骤重新询价", + "不要修改模板 Default", + ] + for fragment in shared_fragments: + assert fragment in repl_prompt + assert fragment in a2a_prompt + + +def test_confirm_a2a_surface_uses_thin_prompt_without_display_tools(): + loaded = load_pipeline_dir(_selling_pipeline_dir()) + confirm = next(step for step in loaded.steps if step.step_id == "confirm_and_select") + a2a = confirm.surface_overrides["a2a"] + + assert a2a.prompt_file == "prompts/confirm_and_select.a2a.md" + assert a2a.inject_tools == [] + + prompt = (_selling_pipeline_dir() / "prompts" / "confirm_and_select.a2a.md").read_text(encoding="utf-8") + assert "`selected_candidate_index`" in prompt + assert "`parameter_overrides`" in prompt + assert "`complete_step.conclusion.user_prompt`" in prompt + assert "不要在本步骤重新询价" in prompt + assert "show_architecture_diagram" not in prompt + assert "show_candidate_detail" not in prompt + + def test_selling_steps_do_not_expose_static_rollback_rules(): loaded = load_pipeline_dir(_selling_pipeline_dir()) From a1c2cb899ac54dc613fd2c9ad03d44e3f8eef71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 23 Jun 2026 10:43:13 +0800 Subject: [PATCH 15/59] fix: enforce ROS stack completion guard --- src/iac_code/pipeline/engine/__init__.py | 17 +- .../pipeline/engine/complete_step_tool.py | 168 +++++++ .../pipeline/engine/completion_guard_state.py | 76 ++++ src/iac_code/pipeline/engine/recovery.py | 30 +- src/iac_code/pipeline/engine/step_executor.py | 67 ++- src/iac_code/pipeline/selling/pipeline.yaml | 11 + .../skills/iac-aliyun-deploying/SKILL.md | 15 + src/iac_code/services/session_index.py | 3 +- .../engine/test_complete_step_tool.py | 170 +++++++ tests/pipeline/engine/test_recovery.py | 52 +++ tests/pipeline/engine/test_step_executor.py | 414 +++++++++++++++++- .../skills/test_iac_aliyun_deploying_skill.py | 13 + .../selling/test_terminal_ui_contract.py | 24 + 13 files changed, 1025 insertions(+), 35 deletions(-) create mode 100644 src/iac_code/pipeline/engine/completion_guard_state.py diff --git a/src/iac_code/pipeline/engine/__init__.py b/src/iac_code/pipeline/engine/__init__.py index 005bcbde..b95db431 100644 --- a/src/iac_code/pipeline/engine/__init__.py +++ b/src/iac_code/pipeline/engine/__init__.py @@ -1,11 +1,14 @@ """Generic pipeline engine — state machine, context, step execution.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from iac_code.pipeline.engine.complete_step_tool import CompleteStepTool from iac_code.pipeline.engine.context import PipelineContext, VersionedField from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.interrupt import InterruptController, InterruptVerdict from iac_code.pipeline.engine.loader import load_pipeline_dir -from iac_code.pipeline.engine.pipeline_runner import PipelineRunner from iac_code.pipeline.engine.session import PipelineSession from iac_code.pipeline.engine.state_machine import StateMachine from iac_code.pipeline.engine.step_executor import StepExecutor @@ -14,6 +17,18 @@ from iac_code.pipeline.engine.types import StepConfig, StepResult, StepStatus from iac_code.pipeline.engine.ui_contract import PipelineStepType, PipelineUiMode +if TYPE_CHECKING: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + +def __getattr__(name: str) -> Any: + if name == "PipelineRunner": + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + return PipelineRunner + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "CompleteStepTool", "A2AArtifactSpec", diff --git a/src/iac_code/pipeline/engine/complete_step_tool.py b/src/iac_code/pipeline/engine/complete_step_tool.py index 9e5236f0..e7f99752 100644 --- a/src/iac_code/pipeline/engine/complete_step_tool.py +++ b/src/iac_code/pipeline/engine/complete_step_tool.py @@ -249,6 +249,7 @@ def _validate_completion_guards(self, conclusion: dict) -> str | None: continue required_tool = guard.get("require_tool") + required_tool_result = guard.get("require_tool_result") required_field = guard.get("required_conclusion_field") required_any_of = guard.get("required_conclusion_any_of") or [] successful_tools = self._completion_guard_state.get("successful_tools", set()) @@ -279,8 +280,117 @@ def _validate_completion_guards(self, conclusion: dict) -> str | None: message=message, fields=fields, ) + if isinstance(required_tool_result, dict): + validation_error = self._validate_required_tool_result( + required_tool_result, + conclusion, + guard.get("message"), + ) + if validation_error is not None: + return validation_error return None + def _validate_required_tool_result( + self, + requirement: dict[str, Any], + conclusion: dict[str, Any], + message: str | None, + ) -> str | None: + tool_name = str(requirement.get("tool") or "") + actions = self._expected_actions(requirement) + expected_success = requirement.get("is_success") + status_in = {str(status) for status in requirement.get("status_in") or [] if status is not None} + match_conclusion_field = requirement.get("match_conclusion_field") + match_result_field = str(requirement.get("match_result_field") or "stack_id") + base_message = message or _("A successful tool result is required before completing the current step.") + + records = self._completion_guard_state.get("tool_result_records") or [] + mismatch_message: str | None = None + for record in records: + if not isinstance(record, dict): + continue + if tool_name and record.get("tool_name") != tool_name: + continue + tool_input = record.get("input") if isinstance(record.get("input"), dict) else {} + if actions and self._first_string(tool_input, ("action", "Action")) not in actions: + continue + if record.get("is_error"): + continue + result = record.get("result") + if not isinstance(result, dict): + continue + if expected_success is not None and self._bool_from_result(result) is not bool(expected_success): + continue + if status_in: + status = self._status_from_result(result) + if status not in status_in: + continue + if isinstance(match_conclusion_field, str) and match_conclusion_field: + conclusion_value = self._resolve_dotted(conclusion, match_conclusion_field) + result_value = ( + self._stack_id_from_result(result) + if match_result_field == "stack_id" + else self._resolve_dotted(result, match_result_field) + ) + if conclusion_value != result_value: + mismatch_message = _( + "{message} complete_step.conclusion.{field} must match the {tool} result value {value}." + ).format( + message=base_message, + field=match_conclusion_field, + tool=tool_name or _("tool"), + value=result_value or _(""), + ) + continue + return None + + if mismatch_message is not None: + return mismatch_message + status_hint = "" + if status_in: + status_hint = _(" with status {statuses}").format(statuses=", ".join(sorted(status_in))) + success_hint = "" + if expected_success is not None: + success_hint = _(" and is_success={expected}").format(expected=str(bool(expected_success)).lower()) + action_hint = "" + if len(actions) == 1: + action_hint = f" {next(iter(actions))}" + elif actions: + action_hint = _(" one of {actions}").format(actions=", ".join(sorted(actions))) + return _( + "{message} Call {tool}{action} first and wait for a successful result{status_hint}{success_hint}." + ).format( + message=base_message, + tool=tool_name or _("the required tool"), + action=action_hint, + status_hint=status_hint, + success_hint=success_hint, + ) + + def validate_completion_input(self, tool_input: dict[str, Any]) -> str | None: + """Validate a complete_step input without mutating retry counters.""" + + self.normalize_input(tool_input) + rollback_target_error = self._validate_rollback_target_limit() + if rollback_target_error is not None: + return rollback_target_error + + conclusion = tool_input["conclusion"] + rollback = tool_input.get("rollback_request") + rollback_tuple = (rollback["target_step"], rollback["reason"]) if rollback else None + if rollback_tuple and self._step_config.rollback_count >= self._step_config.max_rollbacks: + max_rollbacks = self._step_config.max_rollbacks + return _( + "Rollback count cannot exceed {max_rollbacks}. Complete the current step or ask the user for help." + ).format(max_rollbacks=max_rollbacks) + + validation_error = self._validate_conclusion(conclusion) + if validation_error is None: + validation_error = self._validate_completion_guards(conclusion) + if validation_error is None: + validation_error = self._validate_candidate_limit(conclusion) + return validation_error + def _guard_applies(self, guard: dict, conclusion: dict) -> bool: unless_patterns = guard.get("unless_user_message_matches_any") or [] if any(self._matches(pattern, self._user_message) for pattern in unless_patterns): @@ -330,6 +440,64 @@ def _resolve_dotted(value: dict, path: str) -> Any: return None return current + @classmethod + def _status_from_result(cls, result: dict[str, Any]) -> str | None: + nested = cls._dict_value(result.get("Stack") or result.get("stack")) + return cls._first_string( + result, + ("StackStatus", "stackStatus", "stack_status", "Status", "status"), + ) or cls._first_string(nested, ("StackStatus", "stackStatus", "stack_status", "Status", "status")) + + @classmethod + def _stack_id_from_result(cls, result: dict[str, Any]) -> str | None: + nested = cls._dict_value(result.get("Stack") or result.get("stack")) + return cls._first_string(result, ("StackId", "stackId", "stack_id")) or cls._first_string( + nested, + ("StackId", "stackId", "stack_id"), + ) + + @classmethod + def _bool_from_result(cls, result: dict[str, Any]) -> bool | None: + value = result.get("is_success") + if value is None: + value = result.get("isSuccess") + if isinstance(value, bool): + return value + if isinstance(value, str): + lower = value.strip().lower() + if lower in {"true", "1", "yes"}: + return True + if lower in {"false", "0", "no"}: + return False + return None + + @staticmethod + def _dict_value(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + @staticmethod + def _first_string(source: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + value = source.get(key) + if isinstance(value, str) and value: + return value + return None + + @classmethod + def _expected_actions(cls, requirement: dict[str, Any]) -> set[str]: + actions: set[str] = set() + for key in ("action", "action_in", "actions"): + actions.update(cls._string_set(requirement.get(key))) + return actions + + @staticmethod + def _string_set(value: Any) -> set[str]: + if isinstance(value, str): + return {value} if value else set() + if isinstance(value, list | tuple | set): + return {str(item) for item in value if item not in (None, "")} + return set() + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: self.normalize_input(tool_input) rollback_target_error = self._validate_rollback_target_limit() diff --git a/src/iac_code/pipeline/engine/completion_guard_state.py b/src/iac_code/pipeline/engine/completion_guard_state.py new file mode 100644 index 00000000..48a91dad --- /dev/null +++ b/src/iac_code/pipeline/engine/completion_guard_state.py @@ -0,0 +1,76 @@ +"""State helpers for completion guards that depend on prior tool results.""" + +from __future__ import annotations + +import json +from typing import Any + + +def ensure_completion_guard_state(state: dict[str, Any]) -> dict[str, Any]: + state.setdefault("successful_tools", set()) + state.setdefault("tool_results", {}) + state.setdefault("tool_result_records", []) + return state + + +def record_completion_guard_tool_result( + state: dict[str, Any], + *, + tool_name: str, + tool_input: dict[str, Any], + content: Any, + is_error: bool, +) -> None: + """Record tool results that completion guards may need later in the same step.""" + + ensure_completion_guard_state(state) + if tool_name == "ask_user_question": + _record_ask_user_question(state, content, is_error=is_error) + return + if tool_name == "ros_stack": + _record_ros_stack(state, tool_input, content, is_error=is_error) + + +def _record_ask_user_question(state: dict[str, Any], content: Any, *, is_error: bool) -> None: + if is_error: + return + successful_tools: set[str] = state.setdefault("successful_tools", set()) + successful_tools.add("ask_user_question") + tool_results: dict[str, Any] = state.setdefault("tool_results", {}) + parsed = _json_object(content) + if parsed is None: + parsed = { + "selected_id": "", + "selected_label": "", + "free_text": str(content), + } + tool_results["ask_user_question"] = parsed + + +def _record_ros_stack(state: dict[str, Any], tool_input: dict[str, Any], content: Any, *, is_error: bool) -> None: + parsed = _json_object(content) + if parsed is None: + return + records: list[dict[str, Any]] = state.setdefault("tool_result_records", []) + record = { + "tool_name": "ros_stack", + "input": dict(tool_input), + "result": parsed, + "is_error": bool(is_error), + } + records.append(record) + state.setdefault("tool_results", {})["ros_stack"] = parsed + if not is_error: + state.setdefault("successful_tools", set()).add("ros_stack") + + +def _json_object(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + return value + if not isinstance(value, str) or not value: + return None + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None diff --git a/src/iac_code/pipeline/engine/recovery.py b/src/iac_code/pipeline/engine/recovery.py index ce657a45..e5b8f3b4 100644 --- a/src/iac_code/pipeline/engine/recovery.py +++ b/src/iac_code/pipeline/engine/recovery.py @@ -2,10 +2,13 @@ from __future__ import annotations -import json from typing import Any from iac_code.agent.message import Message, ToolResultBlock, ToolUseBlock +from iac_code.pipeline.engine.completion_guard_state import ( + ensure_completion_guard_state, + record_completion_guard_tool_result, +) from iac_code.pipeline.engine.types import StepResult, StepStatus @@ -59,26 +62,21 @@ def reconstruct_step_result(messages: list[Message], step_id: str) -> StepResult def reconstruct_completion_guard_state(messages: list[Message]) -> dict[str, Any]: tool_uses = _tool_uses_by_id(messages) - successful_tools: set[str] = set() - tool_results: dict[str, Any] = {} + state = ensure_completion_guard_state({}) for message in messages: if message.role != "user" or isinstance(message.content, str): continue for block in message.content: - if not isinstance(block, ToolResultBlock) or block.is_error: + if not isinstance(block, ToolResultBlock): continue tool_use = tool_uses.get(block.tool_use_id) if tool_use is None: continue - if tool_use.name != "ask_user_question": - continue - successful_tools.add("ask_user_question") - try: - tool_results["ask_user_question"] = json.loads(block.content) - except json.JSONDecodeError: - tool_results["ask_user_question"] = { - "selected_id": "", - "selected_label": "", - "free_text": block.content, - } - return {"successful_tools": successful_tools, "tool_results": tool_results} + record_completion_guard_tool_result( + state, + tool_name=tool_use.name, + tool_input=tool_use.input, + content=block.content, + is_error=block.is_error, + ) + return state diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 24d4ed06..2abddb28 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -15,6 +15,10 @@ from iac_code.agent.message import ContentBlock, Message from iac_code.agent.system_prompt import SECTION_BUILDERS, build_base_sections from iac_code.pipeline.engine.complete_step_tool import CompleteStepTool +from iac_code.pipeline.engine.completion_guard_state import ( + ensure_completion_guard_state, + record_completion_guard_tool_result, +) from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.events import PipelineEvent from iac_code.pipeline.engine.observability import PipelineObservability @@ -49,6 +53,7 @@ class StepAgentLoopContext: agent_loop: Any | None initial_prompt: str | list[ContentBlock] resume_messages: list[Message] + completion_guard_state: dict[str, Any] restored_step_result: StepResult | None = None @@ -154,8 +159,10 @@ async def execute( agent_loop = agent_context.agent_loop assert agent_loop is not None self._current_agent_loop = agent_loop + completion_guard_state = agent_context.completion_guard_state complete_step_ids: set[str] = set() + pending_tool_inputs: dict[str, dict[str, Any]] = {} pending_complete_input: dict[str, dict] = {} complete_step_input: dict | None = None terminal_failed_step_result: StepResult | None = None @@ -173,17 +180,31 @@ async def consume_complete_step_events( async for event in stream: if isinstance(event, ToolUseStartEvent) and event.name == "complete_step": complete_step_ids.add(event.tool_use_id) - elif isinstance(event, ToolUseEndEvent) and event.tool_use_id in complete_step_ids: - pending_complete_input[event.tool_use_id] = event.input - elif isinstance(event, ToolResultEvent) and event.tool_use_id in complete_step_ids: - step_result = (event.metadata or {}).get("step_result") - if isinstance(step_result, StepResult) and step_result.status == StepStatus.FAILED: - terminal_failed_step_result = step_result - if not event.is_error: - complete_step_input = pending_complete_input.get(event.tool_use_id) - else: - last_complete_step_error = event.result - last_complete_step_input = pending_complete_input.get(event.tool_use_id) + elif isinstance(event, ToolUseEndEvent): + pending_tool_inputs[event.tool_use_id] = {"tool_name": event.name, "input": dict(event.input)} + if event.tool_use_id in complete_step_ids: + pending_complete_input[event.tool_use_id] = event.input + elif isinstance(event, ToolResultEvent): + tool_record = pending_tool_inputs.get(event.tool_use_id) + if isinstance(tool_record, dict): + tool_input_raw = tool_record.get("input") + tool_input: dict[str, Any] = tool_input_raw if isinstance(tool_input_raw, dict) else {} + record_completion_guard_tool_result( + completion_guard_state, + tool_name=str(tool_record.get("tool_name") or event.tool_name), + tool_input=tool_input, + content=event.result, + is_error=event.is_error, + ) + if event.tool_use_id in complete_step_ids: + step_result = (event.metadata or {}).get("step_result") + if isinstance(step_result, StepResult) and step_result.status == StepStatus.FAILED: + terminal_failed_step_result = step_result + if not event.is_error: + complete_step_input = pending_complete_input.get(event.tool_use_id) + else: + last_complete_step_error = event.result + last_complete_step_input = pending_complete_input.get(event.tool_use_id) yield event try: @@ -249,6 +270,7 @@ async def consume_complete_step_events( transcript_id=transcript_id, resume_messages=None, precompleted_tools=None, + completion_guard_state_seed=completion_guard_state, rollback_targets=rollback_targets, rollback_count=rollback_count, max_rollbacks=max_rollbacks, @@ -301,6 +323,7 @@ def build_agent_loop_context( transcript_id: str | None = None, resume_messages: list | None = None, precompleted_tools: dict[str, dict[str, Any]] | None = None, + completion_guard_state_seed: dict[str, Any] | None = None, rollback_targets: list[str] | None = None, rollback_count: int = 0, max_rollbacks: int = 5, @@ -309,12 +332,17 @@ def build_agent_loop_context( initial_prompt = user_message or f"请完成当前步骤:{step.step_id}。" repaired_messages = list(resume_messages or []) - completion_guard_state: dict[str, Any] = reconstruct_completion_guard_state(repaired_messages) - completion_guard_state.setdefault("successful_tools", set()) - completion_guard_state.setdefault("tool_results", {}) + completion_guard_state: dict[str, Any] = ensure_completion_guard_state( + reconstruct_completion_guard_state(repaired_messages) + ) if precompleted_tools: completion_guard_state["successful_tools"].update(precompleted_tools) completion_guard_state["tool_results"].update(precompleted_tools) + if completion_guard_state_seed: + seed = ensure_completion_guard_state(completion_guard_state_seed) + completion_guard_state["successful_tools"].update(seed.get("successful_tools", set())) + completion_guard_state["tool_results"].update(seed.get("tool_results", {})) + completion_guard_state["tool_result_records"].extend(seed.get("tool_result_records", [])) build_tool_kwargs: dict[str, Any] = { "rollback_targets": rollback_targets, @@ -340,6 +368,7 @@ def build_agent_loop_context( agent_loop=None, initial_prompt=initial_prompt, resume_messages=repaired_messages, + completion_guard_state=completion_guard_state, restored_step_result=restored_step_result, ) @@ -368,6 +397,7 @@ def build_agent_loop_context( agent_loop=agent_loop, initial_prompt=initial_prompt, resume_messages=repaired_messages, + completion_guard_state=completion_guard_state, ) @staticmethod @@ -382,7 +412,10 @@ def _restore_completed_step_result( normalized_input = copy.deepcopy(complete_step_input) complete_step_tool = tool_registry.get("complete_step") - if complete_step_tool is not None: + if isinstance(complete_step_tool, CompleteStepTool): + if complete_step_tool.validate_completion_input(normalized_input) is not None: + return None + elif complete_step_tool is not None: complete_step_tool.normalize_input(normalized_input) conclusion = normalized_input.get("conclusion", {}) @@ -657,7 +690,9 @@ def _build_step_tools( rollback_count=rollback_count, max_rollbacks=max_rollbacks, ) - guard_state = completion_guard_state if completion_guard_state is not None else {"successful_tools": set()} + guard_state = ensure_completion_guard_state( + completion_guard_state if completion_guard_state is not None else {} + ) registry.register( CompleteStepTool( step_config, diff --git a/src/iac_code/pipeline/selling/pipeline.yaml b/src/iac_code/pipeline/selling/pipeline.yaml index 86509ff0..acb5962d 100644 --- a/src/iac_code/pipeline/selling/pipeline.yaml +++ b/src/iac_code/pipeline/selling/pipeline.yaml @@ -252,6 +252,17 @@ steps: interrupt_judge_failure: pause context_fields: [intent, selected_plan, evaluated_candidates] hooks_file: hooks/deploying.py + completion_guards: + - when_conclusion_field_equals: + status: success + required_conclusion_field: stack_id + require_tool_result: + tool: ros_stack + action_in: [CreateStack, ContinueCreateStack] + is_success: true + status_in: [CREATE_COMPLETE] + match_conclusion_field: stack_id + message: "部署成功必须等待 ros_stack CreateStack 返回 CREATE_COMPLETE。" tools: include: [] exclude: [write_memory] diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md index 3a4fcf07..7a634858 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md @@ -24,6 +24,21 @@ conclusion_schema: error: type: string description: 失败原因(status 为 failed 时必填) + allOf: + - if: + properties: + status: + const: success + required: [status] + then: + required: [stack_id] + - if: + properties: + status: + const: failed + required: [status] + then: + required: [error] --- # 阿里云 ROS 部署技能 diff --git a/src/iac_code/services/session_index.py b/src/iac_code/services/session_index.py index 1bcbad89..662938f2 100644 --- a/src/iac_code/services/session_index.py +++ b/src/iac_code/services/session_index.py @@ -15,7 +15,6 @@ from pathlib import Path from iac_code.agent.message import RECALLED_MEMORY_MARKER, RECALLED_MEMORY_METADATA_TYPE -from iac_code.pipeline.engine.cleanup import CLEANUP_PROMPT_METADATA_TYPE from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, read_session_metadata from iac_code.utils.project_paths import ( get_project_dir, @@ -25,6 +24,8 @@ ) LITE_READ_BUF_SIZE = 64 * 1024 +# Keep in sync with pipeline.engine.cleanup without importing pipeline.engine in normal mode. +CLEANUP_PROMPT_METADATA_TYPE = "pipeline_cleanup_prompt" @dataclass diff --git a/tests/pipeline/engine/test_complete_step_tool.py b/tests/pipeline/engine/test_complete_step_tool.py index 0272e233..d31514cf 100644 --- a/tests/pipeline/engine/test_complete_step_tool.py +++ b/tests/pipeline/engine/test_complete_step_tool.py @@ -217,6 +217,176 @@ async def test_rejects_when_rollback_target_count_exceeds_limit(self): class TestCompletionGuards: + @staticmethod + def _deploying_success_guard() -> dict: + return { + "when_conclusion_field_equals": {"status": "success"}, + "required_conclusion_field": "stack_id", + "require_tool_result": { + "tool": "ros_stack", + "action_in": ["CreateStack", "ContinueCreateStack"], + "is_success": True, + "status_in": ["CREATE_COMPLETE"], + "match_conclusion_field": "stack_id", + }, + "message": "部署成功必须等待 ros_stack CreateStack 返回 CREATE_COMPLETE。", + } + + @staticmethod + def _deploying_tool(result_records: list[dict] | None = None) -> CompleteStepTool: + config = StepConfig( + step_id="deploying", + conclusion_field="deployment", + forward=None, + conclusion_schema={ + "type": "object", + "required": ["status"], + "additionalProperties": False, + "properties": { + "stack_id": {"type": "string"}, + "status": {"type": "string", "enum": ["success", "failed", "cancelled"]}, + "error": {"type": "string"}, + }, + }, + ) + return CompleteStepTool( + config, + completion_guards=[TestCompletionGuards._deploying_success_guard()], + completion_guard_state={ + "successful_tools": set(), + "tool_results": {}, + "tool_result_records": list(result_records or []), + }, + ) + + @pytest.mark.asyncio + async def test_required_tool_result_rejects_deploying_success_without_create_stack_result(self): + tool = self._deploying_tool() + + result = await tool.execute( + tool_input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + context=ToolContext(), + ) + + assert result.is_error + assert "CreateStack" in result.content + assert "CREATE_COMPLETE" in result.content + + @pytest.mark.asyncio + async def test_required_tool_result_rejects_deploying_success_when_stack_creation_failed(self): + tool = self._deploying_tool( + [ + { + "tool_name": "ros_stack", + "input": {"action": "CreateStack", "params": {"StackName": "demo"}}, + "result": {"stack_id": "stack-123", "status": "CREATE_FAILED", "is_success": False}, + "is_error": True, + } + ] + ) + + result = await tool.execute( + tool_input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + context=ToolContext(), + ) + + assert result.is_error + assert "CREATE_COMPLETE" in result.content + + @pytest.mark.asyncio + async def test_required_tool_result_rejects_deploying_success_when_stack_id_mismatches(self): + tool = self._deploying_tool( + [ + { + "tool_name": "ros_stack", + "input": {"action": "CreateStack", "params": {"StackName": "demo"}}, + "result": {"stack_id": "stack-123", "status": "CREATE_COMPLETE", "is_success": True}, + "is_error": False, + } + ] + ) + + result = await tool.execute( + tool_input={"conclusion": {"status": "success", "stack_id": "stack-other"}}, + context=ToolContext(), + ) + + assert result.is_error + assert "stack_id" in result.content + + @pytest.mark.asyncio + async def test_required_tool_result_accepts_matching_deploying_success(self): + tool = self._deploying_tool( + [ + { + "tool_name": "ros_stack", + "input": {"action": "CreateStack", "params": {"StackName": "demo"}}, + "result": {"stack_id": "stack-123", "status": "CREATE_COMPLETE", "is_success": True}, + "is_error": False, + } + ] + ) + + result = await tool.execute( + tool_input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + context=ToolContext(), + ) + + assert not result.is_error + + @pytest.mark.asyncio + async def test_required_tool_result_accepts_matching_continue_create_stack_success(self): + tool = self._deploying_tool( + [ + { + "tool_name": "ros_stack", + "input": {"action": "ContinueCreateStack", "params": {"StackName": "demo"}}, + "result": {"stack_id": "stack-123", "status": "CREATE_COMPLETE", "is_success": True}, + "is_error": False, + } + ] + ) + + result = await tool.execute( + tool_input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + context=ToolContext(), + ) + + assert not result.is_error + + @pytest.mark.asyncio + async def test_required_tool_result_rejects_non_matching_stack_action(self): + tool = self._deploying_tool( + [ + { + "tool_name": "ros_stack", + "input": {"action": "UpdateStack", "params": {"StackName": "demo"}}, + "result": {"stack_id": "stack-123", "status": "CREATE_COMPLETE", "is_success": True}, + "is_error": False, + } + ] + ) + + result = await tool.execute( + tool_input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + context=ToolContext(), + ) + + assert result.is_error + assert "CreateStack" in result.content + assert "ContinueCreateStack" in result.content + + @pytest.mark.asyncio + async def test_required_tool_result_does_not_block_failed_deploying_conclusion(self): + tool = self._deploying_tool() + + result = await tool.execute( + tool_input={"conclusion": {"status": "failed", "error": "CREATE_FAILED"}}, + context=ToolContext(), + ) + + assert not result.is_error + @pytest.mark.asyncio async def test_required_conclusion_any_of_accepts_clarification_text(self): config = StepConfig(step_id="intent_parsing", conclusion_field="intent", forward=None) diff --git a/tests/pipeline/engine/test_recovery.py b/tests/pipeline/engine/test_recovery.py index 3e9dd837..f4fedcf6 100644 --- a/tests/pipeline/engine/test_recovery.py +++ b/tests/pipeline/engine/test_recovery.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from iac_code.agent.message import Message, TextBlock, ToolResultBlock, ToolUseBlock from iac_code.pipeline.engine.recovery import ( last_successful_tool_input, @@ -164,3 +166,53 @@ def test_reconstruct_completion_guard_state_ignores_successful_non_guard_tools() assert state["successful_tools"] == set() assert state["tool_results"] == {} + + +def test_reconstruct_completion_guard_state_records_ros_stack_results_for_completion_guards(): + messages = [ + Message( + role="assistant", + content=[ + ToolUseBlock( + id="tu_stack", + name="ros_stack", + input={"action": "CreateStack", "params": {"StackName": "demo"}}, + ) + ], + ), + Message( + role="user", + content=[ + ToolResultBlock( + tool_use_id="tu_stack", + content=json.dumps( + { + "stack_id": "stack-123", + "stack_name": "demo", + "status": "CREATE_COMPLETE", + "is_success": True, + } + ), + is_error=False, + ) + ], + ), + ] + + state = reconstruct_completion_guard_state(messages) + + assert state["successful_tools"] == {"ros_stack"} + assert state["tool_results"]["ros_stack"]["stack_id"] == "stack-123" + assert state["tool_result_records"] == [ + { + "tool_name": "ros_stack", + "input": {"action": "CreateStack", "params": {"StackName": "demo"}}, + "result": { + "stack_id": "stack-123", + "stack_name": "demo", + "status": "CREATE_COMPLETE", + "is_success": True, + }, + "is_error": False, + } + ] diff --git a/tests/pipeline/engine/test_step_executor.py b/tests/pipeline/engine/test_step_executor.py index 38c3056a..be2cdb0d 100644 --- a/tests/pipeline/engine/test_step_executor.py +++ b/tests/pipeline/engine/test_step_executor.py @@ -1,3 +1,4 @@ +import json import logging from pathlib import Path from types import SimpleNamespace @@ -10,7 +11,7 @@ from iac_code.pipeline.engine.step_executor import StepExecutor from iac_code.pipeline.engine.step_spec import IncludeExcludeConfig, LoadedPipeline, StepSpec, StepSurfaceOverride from iac_code.pipeline.engine.types import StepResult, StepStatus -from iac_code.tools.base import ToolContext, ToolRegistry +from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult from iac_code.types.stream_events import ( AskUserQuestionEvent, MessageEndEvent, @@ -1416,6 +1417,417 @@ async def stream(self, messages, system, tools=None): assert step_results[-1].conclusion["clarification_choice"] == "deploy_to_aliyun" assert "selected_id" not in step_results[-1].conclusion + @pytest.mark.asyncio + async def test_completion_guard_rejects_deploying_success_until_create_stack_completes(self, tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "deploying.md").write_text("Deploy.", encoding="utf-8") + + class DummyRosStack(Tool): + @property + def name(self) -> str: + return "ros_stack" + + @property + def description(self) -> str: + return "ROS stack" + + @property + def input_schema(self) -> dict: + return { + "type": "object", + "required": ["action"], + "properties": {"action": {"type": "string"}, "params": {"type": "object"}}, + } + + def is_read_only(self, input: dict | None = None) -> bool: + return True + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + assert tool_input["action"] == "CreateStack" + return ToolResult.success( + json.dumps( + { + "stack_id": "stack-123", + "stack_name": "demo", + "status": "CREATE_COMPLETE", + "is_success": True, + } + ) + ) + + step = StepSpec( + step_id="deploying", + conclusion_field="deployment", + forward=None, + prompt_file="prompts/deploying.md", + conclusion_schema={ + "type": "object", + "required": ["status"], + "additionalProperties": False, + "properties": { + "stack_id": {"type": "string"}, + "status": {"type": "string", "enum": ["success", "failed", "cancelled"]}, + "error": {"type": "string"}, + }, + }, + completion_guards=[ + { + "when_conclusion_field_equals": {"status": "success"}, + "required_conclusion_field": "stack_id", + "require_tool_result": { + "tool": "ros_stack", + "action_in": ["CreateStack", "ContinueCreateStack"], + "is_success": True, + "status_in": ["CREATE_COMPLETE"], + "match_conclusion_field": "stack_id", + }, + "message": "部署成功必须等待 ros_stack CreateStack 返回 CREATE_COMPLETE。", + } + ], + max_agent_turns=8, + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"deployment": []}, + max_rollbacks=3, + skills={}, + ) + registry = ToolRegistry() + registry.register(DummyRosStack()) + + class Provider: + def __init__(self) -> None: + self.calls = 0 + + def get_model_name(self) -> str: + return "test-model" + + async def stream(self, messages, system, tools=None): + self.calls += 1 + if self.calls == 1: + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="done_bad", name="complete_step") + yield ToolUseEndEvent( + tool_use_id="done_bad", + name="complete_step", + input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + + if self.calls == 2: + assert any( + getattr(block, "type", None) == "tool_result" + and getattr(block, "tool_use_id", None) == "done_bad" + and getattr(block, "is_error", False) + and "CreateStack" in getattr(block, "content", "") + for message in messages + for block in (message.content if isinstance(message.content, list) else []) + ) + yield MessageStartEvent(message_id="m2") + yield ToolUseStartEvent(tool_use_id="stack_1", name="ros_stack") + yield ToolUseEndEvent( + tool_use_id="stack_1", + name="ros_stack", + input={"action": "CreateStack", "params": {"StackName": "demo"}}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + + if self.calls == 3: + yield MessageStartEvent(message_id="m3") + yield ToolUseStartEvent(tool_use_id="done_good", name="complete_step") + yield ToolUseEndEvent( + tool_use_id="done_good", + name="complete_step", + input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + + yield MessageStartEvent(message_id="m4") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + provider = Provider() + executor = StepExecutor( + provider_manager=provider, + base_tool_registry=registry, + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + + collected = [] + async for event in executor.execute(step, PipelineContext({"deployment": []}), "test"): + collected.append(event) + + complete_results = [ + event for event in collected if isinstance(event, ToolResultEvent) and event.tool_name == "complete_step" + ] + assert provider.calls == 3 + assert complete_results[0].is_error + assert "CreateStack" in complete_results[0].result + assert complete_results[-1].is_error is False + step_results = [event for event in collected if isinstance(event, StepResult)] + assert step_results[-1].status == StepStatus.COMPLETED + assert step_results[-1].conclusion == {"status": "success", "stack_id": "stack-123"} + + @pytest.mark.asyncio + async def test_fresh_complete_step_recovery_preserves_create_stack_guard_state(self, tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "deploying.md").write_text("Deploy.", encoding="utf-8") + + class DummyRosStack(Tool): + @property + def name(self) -> str: + return "ros_stack" + + @property + def description(self) -> str: + return "ROS stack" + + @property + def input_schema(self) -> dict: + return { + "type": "object", + "required": ["action"], + "properties": {"action": {"type": "string"}, "params": {"type": "object"}}, + } + + def is_read_only(self, input: dict | None = None) -> bool: + return True + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + return ToolResult.success( + json.dumps( + { + "stack_id": "stack-123", + "stack_name": "demo", + "status": "CREATE_COMPLETE", + "is_success": True, + } + ) + ) + + step = StepSpec( + step_id="deploying", + conclusion_field="deployment", + forward=None, + prompt_file="prompts/deploying.md", + conclusion_schema={ + "type": "object", + "required": ["status"], + "additionalProperties": False, + "properties": { + "stack_id": {"type": "string"}, + "status": {"type": "string", "enum": ["success", "failed", "cancelled"]}, + "error": {"type": "string"}, + }, + }, + completion_guards=[ + { + "when_conclusion_field_equals": {"status": "success"}, + "required_conclusion_field": "stack_id", + "require_tool_result": { + "tool": "ros_stack", + "action_in": ["CreateStack", "ContinueCreateStack"], + "is_success": True, + "status_in": ["CREATE_COMPLETE"], + "match_conclusion_field": "stack_id", + }, + } + ], + max_agent_turns=8, + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"deployment": []}, + max_rollbacks=3, + skills={}, + ) + registry = ToolRegistry() + registry.register(DummyRosStack()) + + class Provider: + def __init__(self) -> None: + self.calls = 0 + + def get_model_name(self) -> str: + return "test-model" + + async def stream(self, messages, system, tools=None): + self.calls += 1 + if self.calls == 1: + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="stack_1", name="ros_stack") + yield ToolUseEndEvent( + tool_use_id="stack_1", + name="ros_stack", + input={"action": "CreateStack", "params": {"StackName": "demo"}}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + + if self.calls in {2, 3}: + yield MessageStartEvent(message_id=f"m{self.calls}") + yield ToolUseStartEvent(tool_use_id=f"done_bad_{self.calls}", name="complete_step") + yield ToolUseEndEvent( + tool_use_id=f"done_bad_{self.calls}", + name="complete_step", + input={}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + + if self.calls == 4: + yield MessageStartEvent(message_id="m4") + yield ToolUseStartEvent(tool_use_id="done_good", name="complete_step") + yield ToolUseEndEvent( + tool_use_id="done_good", + name="complete_step", + input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + + yield MessageStartEvent(message_id="m5") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + provider = Provider() + executor = StepExecutor( + provider_manager=provider, + base_tool_registry=registry, + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + + collected = [] + async for event in executor.execute(step, PipelineContext({"deployment": []}), "test"): + collected.append(event) + + assert provider.calls == 4 + complete_results = [ + event for event in collected if isinstance(event, ToolResultEvent) and event.tool_name == "complete_step" + ] + assert complete_results[-1].is_error is False + step_results = [event for event in collected if isinstance(event, StepResult)] + assert step_results[-1].status == StepStatus.COMPLETED + assert step_results[-1].conclusion == {"status": "success", "stack_id": "stack-123"} + + @pytest.mark.asyncio + async def test_resumed_completed_step_revalidates_completion_guards(self, monkeypatch, tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "deploying.md").write_text("Deploy.", encoding="utf-8") + calls: list[str] = [] + + class FakeAgentLoop: + def __init__(self, **kwargs): + self.resume_messages = kwargs.get("resume_messages") + + async def continue_streaming(self): + calls.append("continue") + yield ToolUseStartEvent(tool_use_id="stack_1", name="ros_stack") + yield ToolUseEndEvent( + tool_use_id="stack_1", + name="ros_stack", + input={"action": "CreateStack", "params": {"StackName": "demo"}}, + ) + yield ToolResultEvent( + tool_use_id="stack_1", + tool_name="ros_stack", + result=json.dumps( + { + "stack_id": "stack-123", + "stack_name": "demo", + "status": "CREATE_COMPLETE", + "is_success": True, + } + ), + ) + yield ToolUseStartEvent(tool_use_id="done_good", name="complete_step") + yield ToolUseEndEvent( + tool_use_id="done_good", + name="complete_step", + input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + ) + yield ToolResultEvent(tool_use_id="done_good", tool_name="complete_step", result="ok") + + async def run_streaming(self, user_input): + raise AssertionError("resumed step should continue, not start a fresh prompt") + + monkeypatch.setattr("iac_code.agent.agent_loop.AgentLoop", FakeAgentLoop) + + step = StepSpec( + step_id="deploying", + conclusion_field="deployment", + forward=None, + prompt_file="prompts/deploying.md", + conclusion_schema={ + "type": "object", + "required": ["status"], + "additionalProperties": False, + "properties": { + "stack_id": {"type": "string"}, + "status": {"type": "string", "enum": ["success", "failed", "cancelled"]}, + "error": {"type": "string"}, + }, + }, + completion_guards=[ + { + "when_conclusion_field_equals": {"status": "success"}, + "required_conclusion_field": "stack_id", + "require_tool_result": { + "tool": "ros_stack", + "action_in": ["CreateStack", "ContinueCreateStack"], + "is_success": True, + "status_in": ["CREATE_COMPLETE"], + "match_conclusion_field": "stack_id", + }, + } + ], + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"deployment": []}, + max_rollbacks=3, + skills={}, + ) + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + resume_messages = [ + Message( + role="assistant", + content=[ + ToolUseBlock( + id="done_old", + name="complete_step", + input={"conclusion": {"status": "success", "stack_id": "stack-123"}}, + ) + ], + ), + Message(role="user", content=[ToolResultBlock(tool_use_id="done_old", content="ok", is_error=False)]), + ] + + collected = [] + async for event in executor.execute( + step, + PipelineContext({"deployment": []}), + "test", + resume_messages=resume_messages, + ): + collected.append(event) + + assert calls == ["continue"] + step_results = [event for event in collected if isinstance(event, StepResult)] + assert step_results[-1].status == StepStatus.COMPLETED + assert step_results[-1].conclusion == {"status": "success", "stack_id": "stack-123"} + class TestPipelineToolsDiscovery: def test_inject_tool_from_pipeline_tools_dir(self, tmp_path): diff --git a/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py b/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py index ef327b47..267f96a4 100644 --- a/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py +++ b/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py @@ -1,6 +1,7 @@ import json from pathlib import Path +import jsonschema import pytest import yaml @@ -55,6 +56,18 @@ def test_description_mentions_ros(self): fm = _parse_frontmatter(content) assert "ROS" in fm["description"] + def test_conclusion_schema_requires_stack_id_for_success_and_error_for_failed(self): + content = SKILL_MD.read_text(encoding="utf-8") + fm = _parse_frontmatter(content) + schema = fm["conclusion_schema"] + + jsonschema.validate({"status": "success", "stack_id": "stack-123"}, schema) + jsonschema.validate({"status": "failed", "error": "CREATE_FAILED"}, schema) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"status": "success"}, schema) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"status": "failed"}, schema) + class TestSkillContentRosOnly: @pytest.fixture() diff --git a/tests/pipeline/selling/test_terminal_ui_contract.py b/tests/pipeline/selling/test_terminal_ui_contract.py index 18324f47..af0d60ac 100644 --- a/tests/pipeline/selling/test_terminal_ui_contract.py +++ b/tests/pipeline/selling/test_terminal_ui_contract.py @@ -95,3 +95,27 @@ def test_deploying_pauses_when_interrupt_judge_fails(): deploying = next(step for step in loaded.steps if step.step_id == "deploying") assert deploying.interrupt_judge_failure == "pause" + + +def test_deploying_success_requires_create_stack_complete_guard(): + loaded = load_pipeline_dir(_selling_pipeline_dir()) + deploying = next(step for step in loaded.steps if step.step_id == "deploying") + + guard = next( + ( + item + for item in deploying.completion_guards + if item.get("when_conclusion_field_equals") == {"status": "success"} + ), + None, + ) + + assert guard is not None + assert guard["required_conclusion_field"] == "stack_id" + assert guard["require_tool_result"] == { + "tool": "ros_stack", + "action_in": ["CreateStack", "ContinueCreateStack"], + "is_success": True, + "status_in": ["CREATE_COMPLETE"], + "match_conclusion_field": "stack_id", + } From efad1e0279f13476be81caf1f4322132e15c96ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 23 Jun 2026 11:11:44 +0800 Subject: [PATCH 16/59] fix: require unique ROS stack names --- .../pipeline/selling/skills/iac-aliyun-cost/SKILL.md | 2 ++ .../pipeline/selling/skills/iac-aliyun-deploying/SKILL.md | 7 +++++++ .../pipeline/selling/skills/test_iac_aliyun_cost_skill.py | 5 +++++ .../selling/skills/test_iac_aliyun_deploying_skill.py | 5 +++++ 4 files changed, 19 insertions(+) diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md index 2d379ec4..fcedac4e 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md @@ -97,6 +97,8 @@ aliyun_api( 缺少 Default 或上下文值时,按 [references/template-parameter-recommendation.md](references/template-parameter-recommendation.md) 的参数推荐规则求解,并优先通过 `aliyun_api(product="ros", action="PreviewStack")` 形成 **Preview-Validated Pricing Parameter Set**。不要使用 `ros_stack` 执行 `PreviewStack`;本步骤只验证参数与模板可预览,不执行部署确认或 `CreateStack`。 +PreviewStack 必须传 StackName;调用 PreviewStack 前,必须先确定唯一 `StackName` 并传入 `PreviewStack` 参数。`StackName` 使用候选方案或服务简名作为前缀,并追加时间或 6 位小写字母/数字随机串后缀(如 `ai-app-20260623-a1b2c3`),避免重名。该 `StackName` 是 ROS API 参数,不写入模板 `Parameters`,不放入 `deployment_parameters`。 + PreviewStack 不是硬门禁。它要求完整部署参数,常比 `GetTemplateEstimateCost` 需要更多外部输入;如果完整部署参数无法自动补齐、或 PreviewStack 因外部参数缺口失败,但已有参数足以询价,则可以调用 `GetTemplateEstimateCost` 估算费用。此时必须在 `parameter_set_summary` 说明 PreviewStack 状态,在 `missing_deployment_parameters` 列出缺口,后续选择阶段可通过 `parameter_overrides` 补齐,deploying 再做最终部署校验。 本步骤的裁剪规则: diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md index 7a634858..08b0c649 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md @@ -95,6 +95,13 @@ CreateStack 前按以下优先级确定 `Parameters`: 装配参数时不得改写模板 `Default`,不得编造缺失的外部输入(LicenseKey、Token、证书、真实域名、已有资源 ID、VpcId、VSwitchId、SecurityGroupId、KeyPairName 等)。参数不可用或 CreateStack 无法成功时,优先调整非用户指定参数;仍无法成功创建资源栈时,才可调整用户指定参数。部署步骤不计算费用。 +## StackName + +新建 Stack 时,一开始就确定唯一 `StackName`,并传给 `CreateStack`。`StackName` 使用方案或服务简名作为前缀,并追加时间或 6 位小写字母/数字随机串后缀(如 `ai-app-20260623-a1b2c3`),避免重名。 + +- CreateStack 必须传 StackName,不要省略,不要使用容易重复的固定名称。 +- `UpdateStack`、`ContinueCreateStack`、`DeleteStack` 面向已有 Stack 时,使用上下文中的现有 Stack 标识,不要生成新的 StackName。 + ## 执行部署 - 使用 ros_stack 工具执行 CreateStack/UpdateStack/ContinueCreateStack/DeleteStack,禁止用 Bash diff --git a/tests/pipeline/selling/skills/test_iac_aliyun_cost_skill.py b/tests/pipeline/selling/skills/test_iac_aliyun_cost_skill.py index a773fc09..51ba6007 100644 --- a/tests/pipeline/selling/skills/test_iac_aliyun_cost_skill.py +++ b/tests/pipeline/selling/skills/test_iac_aliyun_cost_skill.py @@ -140,6 +140,11 @@ def test_preview_stack_uses_aliyun_api_not_ros_stack(self, body): assert 'aliyun_api(product="ros", action="PreviewStack")' in body assert "不要使用 `ros_stack` 执行 `PreviewStack`" in body + def test_preview_stack_must_pass_stack_name_with_random_suffix(self, body): + assert "PreviewStack 必须传 StackName" in body + assert "随机串后缀" in body + assert "避免重名" in body + def test_parameter_recommendation_precedes_initial_pricing(self, body): assert "先直接询价" not in body assert "首次询价前" in body diff --git a/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py b/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py index 267f96a4..5cb964df 100644 --- a/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py +++ b/tests/pipeline/selling/skills/test_iac_aliyun_deploying_skill.py @@ -97,6 +97,11 @@ def test_deploying_uses_parameters_without_preview_recommendation(self, body): assert "Preview-Validated Parameter Set" not in body assert "参数推荐" not in body + def test_create_stack_name_has_random_suffix(self, body): + assert "StackName" in body + assert "随机串后缀" in body + assert "避免重名" in body + def test_prefers_cost_deployment_parameters(self, body): assert "selected_plan.selected_candidate_result.cost.deployment_parameters" in body assert "按以下优先级" in body From e928d0c6171aec85d7efee086f0c9e7a5e29e8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 23 Jun 2026 12:43:16 +0800 Subject: [PATCH 17/59] fix: restrict ROS TemplateBody in pipeline mode --- src/iac_code/agent/agent_loop.py | 4 + src/iac_code/pipeline/engine/step_executor.py | 1 + src/iac_code/tools/base.py | 2 + src/iac_code/tools/cloud/aliyun/aliyun_api.py | 3 + src/iac_code/tools/cloud/aliyun/ros_stack.py | 11 +- .../tools/cloud/aliyun/template_source.py | 17 ++ src/iac_code/tools/cloud/base_stack.py | 5 +- src/iac_code/tools/tool_executor.py | 1 + tests/pipeline/engine/test_step_executor.py | 10 ++ tests/tools/cloud/aliyun/test_aliyun_api.py | 61 +++++++- tests/tools/cloud/aliyun/test_ros_stack.py | 147 +++++++++++++----- tests/tools/test_tool_executor.py | 15 ++ 12 files changed, 231 insertions(+), 46 deletions(-) create mode 100644 src/iac_code/tools/cloud/aliyun/template_source.py diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 531caad0..9e3e1154 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -151,6 +151,7 @@ def __init__( 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 @@ -165,6 +166,7 @@ def __init__( 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 @@ -175,6 +177,7 @@ def __init__( self._memory_recall_active_turns = 0 self._last_provider_request_snapshot: dict[str, Any] | None = None self._system_prompt_refresher = system_prompt_refresher + self._pipeline_mode = pipeline_mode model_name = "" if hasattr(provider_manager, "get_model_name"): @@ -941,6 +944,7 @@ async def _run_streaming_inner( 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] = [] diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 2abddb28..b7f5f1ed 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -392,6 +392,7 @@ def build_agent_loop_context( auto_trigger_skills=self._resolve_auto_trigger_skills(step), tool_context_trusted_read_directories=step_skill_roots, tool_context_relative_read_directories=step_skill_roots, + pipeline_mode=True, ) return StepAgentLoopContext( agent_loop=agent_loop, diff --git a/src/iac_code/tools/base.py b/src/iac_code/tools/base.py index f7108cb1..a50ffb72 100644 --- a/src/iac_code/tools/base.py +++ b/src/iac_code/tools/base.py @@ -29,6 +29,8 @@ class ToolContext: # ToolExecutor on each call. tool_use_id: str | None = None relative_read_directories: list[str] = field(default_factory=list) + # True when this tool call is being executed as part of a pipeline step. + pipeline_mode: bool = False @dataclass diff --git a/src/iac_code/tools/cloud/aliyun/aliyun_api.py b/src/iac_code/tools/cloud/aliyun/aliyun_api.py index 0f1bf62a..6fd40bb9 100644 --- a/src/iac_code/tools/cloud/aliyun/aliyun_api.py +++ b/src/iac_code/tools/cloud/aliyun/aliyun_api.py @@ -21,6 +21,7 @@ from iac_code.services.telemetry.names import Events, Metrics from iac_code.services.telemetry.sanitize import sanitize_error_message from iac_code.tools.base import ToolContext, ToolResult +from iac_code.tools.cloud.aliyun.template_source import reject_template_body_param from iac_code.tools.cloud.aliyun.user_agent import build_user_agent from iac_code.tools.cloud.base_api import BaseCloudApi from iac_code.types.stream_events import ResourceObservedEvent @@ -413,6 +414,8 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> # ROS: TemplateURL as local file path → read into TemplateBody if product == "ros": + if error := reject_template_body_param(params, pipeline_mode=context.pipeline_mode): + return ToolResult.error(error) template_url = params.get("TemplateURL", "") if template_url and not template_url.startswith(("http://", "https://", "oss://")): params["TemplateBody"] = Path(template_url).read_text(encoding="utf-8") diff --git a/src/iac_code/tools/cloud/aliyun/ros_stack.py b/src/iac_code/tools/cloud/aliyun/ros_stack.py index add19250..0b04c7d8 100644 --- a/src/iac_code/tools/cloud/aliyun/ros_stack.py +++ b/src/iac_code/tools/cloud/aliyun/ros_stack.py @@ -22,7 +22,9 @@ sanitize_resource_type, sanitize_terraform_provider, ) +from iac_code.tools.base import ToolContext from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory +from iac_code.tools.cloud.aliyun.template_source import reject_template_body_param from iac_code.tools.cloud.base_stack import BaseCloudStack from iac_code.tools.cloud.types import ResourceStatus, StackStatus @@ -472,7 +474,12 @@ def _get_client(self, region: str) -> Any: cred = credentials.get_provider("aliyun") return RosClientFactory.create(cred, region_id=region) - async def call_action(self, action: str, params: dict, region: str) -> str: + def _call_action_kwargs(self, context: ToolContext) -> dict[str, Any]: + return {"pipeline_mode": context.pipeline_mode} + + async def call_action(self, action: str, params: dict, region: str, *, pipeline_mode: bool = False) -> str: + if error := reject_template_body_param(params, pipeline_mode=pipeline_mode): + raise ValueError(error) client = self._get_client(region) # Ensure RegionId is always in params for the API request if region: @@ -482,7 +489,7 @@ async def call_action(self, action: str, params: dict, region: str) -> str: if template_url and not template_url.startswith(_URL_SCHEMES): params["TemplateBody"] = Path(template_url).read_text(encoding="utf-8") del params["TemplateURL"] - # TemplateBody must be a JSON string; models may pass a dict + # TemplateBody must be a JSON string; non-pipeline callers may still pass a dict. if isinstance(params.get("TemplateBody"), dict): params["TemplateBody"] = json.dumps(params["TemplateBody"], ensure_ascii=False) diff --git a/src/iac_code/tools/cloud/aliyun/template_source.py b/src/iac_code/tools/cloud/aliyun/template_source.py new file mode 100644 index 00000000..54b82fb5 --- /dev/null +++ b/src/iac_code/tools/cloud/aliyun/template_source.py @@ -0,0 +1,17 @@ +"""ROS template source parameter helpers.""" + +from __future__ import annotations + +from typing import Any + +from iac_code.i18n import _ + + +def reject_template_body_param(params: dict[str, Any], *, pipeline_mode: bool) -> str | None: + """Return an error message when a caller provides TemplateBody directly.""" + if not pipeline_mode or "TemplateBody" not in params: + return None + return _( + "ROS template calls must use TemplateURL instead of TemplateBody. " + "Save the template to a file and pass params.TemplateURL, for example a local file path or OSS/HTTP URL." + ) diff --git a/src/iac_code/tools/cloud/base_stack.py b/src/iac_code/tools/cloud/base_stack.py index fe9d8928..a2099a6e 100644 --- a/src/iac_code/tools/cloud/base_stack.py +++ b/src/iac_code/tools/cloud/base_stack.py @@ -138,6 +138,9 @@ def user_facing_name(self, input: dict | None = None) -> str: def _resolve_region(self, input: dict) -> str: return input.get("region_id") or self._get_default_region() + def _call_action_kwargs(self, context: ToolContext) -> dict[str, Any]: + return {} + def render_tool_use_message(self, input: dict, *, verbose: bool = False) -> str | None: action = input.get("action", "") region = self._resolve_region(input) @@ -223,7 +226,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> region = self._resolve_region(tool_input) try: - stack_id = await self.call_action(action, params, region) + stack_id = await self.call_action(action, params, region, **self._call_action_kwargs(context)) except Exception as e: return ToolResult.error(f"[{action}] {e}") diff --git a/src/iac_code/tools/tool_executor.py b/src/iac_code/tools/tool_executor.py index 12f8848a..dd29fb05 100644 --- a/src/iac_code/tools/tool_executor.py +++ b/src/iac_code/tools/tool_executor.py @@ -73,6 +73,7 @@ async def _validate_and_execute(self, call: ToolCallRequest, context: ToolContex trusted_read_directories=list(context.trusted_read_directories), relative_read_directories=list(context.relative_read_directories), tool_use_id=call.id, + pipeline_mode=context.pipeline_mode, ) timeout = tool.timeout if tool.timeout is not None else self._tool_timeout diff --git a/tests/pipeline/engine/test_step_executor.py b/tests/pipeline/engine/test_step_executor.py index be2cdb0d..b7b62098 100644 --- a/tests/pipeline/engine/test_step_executor.py +++ b/tests/pipeline/engine/test_step_executor.py @@ -102,6 +102,16 @@ def test_complete_step_tool_registered(self, tmp_path): tool_reg = executor._build_step_tools(step, ctx) assert tool_reg.get("complete_step") is not None + def test_agent_loop_context_marks_pipeline_mode(self, tmp_path): + executor = _make_executor(tmp_path) + step = _make_step() + ctx = PipelineContext(SIMPLE_DEPS) + + agent_context = executor.build_agent_loop_context(step, ctx, "test_session") + + assert agent_context.agent_loop is not None + assert agent_context.agent_loop._pipeline_mode is True + def test_full_tools_when_step_returns_none(self, tmp_path): registry = ToolRegistry() diff --git a/tests/tools/cloud/aliyun/test_aliyun_api.py b/tests/tools/cloud/aliyun/test_aliyun_api.py index c24f3e2b..f130ec4f 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api.py @@ -604,8 +604,57 @@ async def test_uppercase_product_works(self, api: AliyunApi, context: ToolContex class TestAliyunApiHooks: @pytest.mark.asyncio - async def test_hook_blocks_validate_with_wrong_resource_types( + async def test_ros_template_body_is_rejected_before_cloud_call( + self, api: AliyunApi, context: ToolContext, mock_credentials + ) -> None: + with patch("iac_code.tools.cloud.aliyun.aliyun_api.OpenApiClient") as mock_open_api_client: + result = await api.execute( + tool_input={ + "product": "ros", + "action": "ValidateTemplate", + "params": {"TemplateBody": "{}"}, + "region_id": "cn-hangzhou", + }, + context=ToolContext(pipeline_mode=True), + ) + + assert result.is_error is True + assert "TemplateBody" in result.content + assert "TemplateURL" in result.content + mock_open_api_client.assert_not_called() + + @pytest.mark.asyncio + async def test_ros_template_body_is_allowed_outside_pipeline( self, api: AliyunApi, context: ToolContext, mock_credentials + ) -> None: + template = json.dumps( + { + "ROSTemplateFormatVersion": "2015-09-01", + "Resources": { + "Vpc": {"Type": "ALIYUN::ECS::VPC", "Properties": {}}, + }, + } + ) + mock_client = MagicMock() + mock_client.call_api.return_value = {"body": {"Description": "Valid"}} + + with patch("iac_code.tools.cloud.aliyun.aliyun_api.OpenApiClient", return_value=mock_client): + result = await api.execute( + tool_input={ + "product": "ros", + "action": "ValidateTemplate", + "params": {"TemplateBody": template}, + "region_id": "cn-hangzhou", + }, + context=context, + ) + + assert result.is_error is False + mock_client.call_api.assert_called_once() + + @pytest.mark.asyncio + async def test_hook_blocks_validate_with_wrong_resource_types( + self, api: AliyunApi, context: ToolContext, mock_credentials, tmp_path ) -> None: template = json.dumps( { @@ -616,11 +665,13 @@ async def test_hook_blocks_validate_with_wrong_resource_types( }, } ) + template_file = tmp_path / "wrong-resource-types.json" + template_file.write_text(template, encoding="utf-8") result = await api.execute( tool_input={ "product": "ros", "action": "ValidateTemplate", - "params": {"TemplateBody": template}, + "params": {"TemplateURL": str(template_file)}, "region_id": "cn-hangzhou", }, context=context, @@ -631,7 +682,7 @@ async def test_hook_blocks_validate_with_wrong_resource_types( @pytest.mark.asyncio async def test_hook_passes_correct_resource_types( - self, api: AliyunApi, context: ToolContext, mock_credentials + self, api: AliyunApi, context: ToolContext, mock_credentials, tmp_path ) -> None: template = json.dumps( { @@ -641,6 +692,8 @@ async def test_hook_passes_correct_resource_types( }, } ) + template_file = tmp_path / "correct-resource-types.json" + template_file.write_text(template, encoding="utf-8") mock_client = MagicMock() mock_client.call_api.return_value = {"body": {"Description": "Valid"}} @@ -649,7 +702,7 @@ async def test_hook_passes_correct_resource_types( tool_input={ "product": "ros", "action": "ValidateTemplate", - "params": {"TemplateBody": template}, + "params": {"TemplateURL": str(template_file)}, "region_id": "cn-hangzhou", }, context=context, diff --git a/tests/tools/cloud/aliyun/test_ros_stack.py b/tests/tools/cloud/aliyun/test_ros_stack.py index f765dd60..8f9d2269 100644 --- a/tests/tools/cloud/aliyun/test_ros_stack.py +++ b/tests/tools/cloud/aliyun/test_ros_stack.py @@ -13,10 +13,7 @@ from iac_code.tools.cloud.aliyun.ros_stack import RosStack from iac_code.types.stream_events import StackProgressEvent -_MINIMAL_TEMPLATE_BODY = ( - '{"ROSTemplateFormatVersion": "2015-09-01", ' - '"Resources": {"Vpc": {"Type": "ALIYUN::ECS::VPC", "Properties": {"CidrBlock": "192.168.0.0/16"}}}}' -) +_REMOTE_TEMPLATE_URL = "oss://iac-code-test/template.json" @pytest.fixture @@ -118,7 +115,7 @@ async def test_execute_create_stack(self, tool: RosStack, mock_credentials) -> N result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ctx, @@ -144,6 +141,64 @@ async def test_execute_create_stack(self, tool: RosStack, mock_credentials) -> N assert first.resources[0]["name"] == "Vpc" assert first.resources[0]["resource_type"] == "ALIYUN::ECS::VPC" + @pytest.mark.asyncio + async def test_template_body_is_rejected_before_create_stack(self, tool: RosStack, mock_credentials) -> None: + mock_client = MagicMock() + + with patch("iac_code.tools.cloud.aliyun.ros_stack.RosClientFactory") as mock_factory: + mock_factory.create.return_value = mock_client + result = await tool.execute( + tool_input={ + "action": "CreateStack", + "params": {"StackName": "test", "TemplateBody": "{}"}, + "region_id": "cn-hangzhou", + }, + context=ToolContext(pipeline_mode=True), + ) + + assert result.is_error is True + assert "TemplateBody" in result.content + assert "TemplateURL" in result.content + mock_client.create_stack.assert_not_called() + + @pytest.mark.asyncio + async def test_template_body_is_allowed_outside_pipeline(self, tool: RosStack, mock_credentials) -> None: + mock_client = MagicMock() + + create_response = MagicMock() + create_response.body.stack_id = "stack-123" + mock_client.create_stack.return_value = create_response + + get_stack_response = MagicMock() + get_stack_response.body.to_map.return_value = { + "StackId": "stack-123", + "StackName": "test", + "Status": "CREATE_COMPLETE", + "StatusReason": "", + } + mock_client.get_stack.return_value = get_stack_response + + list_resources_response = MagicMock() + list_resources_response.body.to_map.return_value = {"Resources": []} + mock_client.list_stack_resources.return_value = list_resources_response + + with ( + patch("iac_code.tools.cloud.aliyun.ros_stack.RosClientFactory") as mock_factory, + patch("iac_code.tools.cloud.aliyun.api_hooks.run_hooks", return_value=None), + ): + mock_factory.create.return_value = mock_client + result = await tool.execute( + tool_input={ + "action": "CreateStack", + "params": {"StackName": "test", "TemplateBody": "{}"}, + "region_id": "cn-hangzhou", + }, + context=ToolContext(), + ) + + assert result.is_error is False + mock_client.create_stack.assert_called_once() + @pytest.mark.asyncio async def test_create_stack_emits_success_telemetry_only_after_terminal_success( self, tool: RosStack, mock_credentials @@ -188,7 +243,7 @@ def record_event(event_name: str, metadata: dict | None = None) -> None: result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -240,7 +295,7 @@ def flaky_log_event(event_name: str, metadata: dict | None = None) -> None: result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -281,7 +336,7 @@ async def test_create_stack_polling_cancellation_cleans_context_and_emits_cancel await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -337,7 +392,7 @@ def flaky_log_event(event_name: str, metadata: dict | None = None) -> None: result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -382,7 +437,7 @@ def flaky_add_metric(name: str, value: int, attrs: dict | None = None) -> None: result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -393,7 +448,7 @@ def flaky_add_metric(name: str, value: int, attrs: dict | None = None) -> None: @pytest.mark.asyncio async def test_create_stack_non_string_resource_type_does_not_prevent_api_call( - self, tool: RosStack, mock_credentials + self, tool: RosStack, mock_credentials, tmp_path ) -> None: mock_client = MagicMock() @@ -415,6 +470,8 @@ async def test_create_stack_non_string_resource_type_does_not_prevent_api_call( mock_client.list_stack_resources.return_value = list_resources_response template_body = json.dumps({"Resources": {"R": {"Type": 123}}}) + template_file = tmp_path / "non-string-resource-type.json" + template_file.write_text(template_body, encoding="utf-8") with ( patch("iac_code.tools.cloud.aliyun.ros_stack.RosClientFactory") as mock_factory, @@ -424,7 +481,7 @@ async def test_create_stack_non_string_resource_type_does_not_prevent_api_call( result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": template_body}, + "params": {"StackName": "test", "TemplateURL": str(template_file)}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -436,7 +493,7 @@ async def test_create_stack_non_string_resource_type_does_not_prevent_api_call( assert data["status"] == "CREATE_COMPLETE" @pytest.mark.asyncio - async def test_create_stack_none_template_body_does_not_prevent_api_call( + async def test_create_stack_template_url_does_not_require_template_body( self, tool: RosStack, mock_credentials ) -> None: mock_client = MagicMock() @@ -466,7 +523,7 @@ async def test_create_stack_none_template_body_does_not_prevent_api_call( result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": None}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -475,7 +532,7 @@ async def test_create_stack_none_template_body_does_not_prevent_api_call( assert result.is_error is False mock_client.create_stack.assert_called_once() request = mock_client.create_stack.call_args.args[0] - assert request.template_body is None + assert request.to_map()["TemplateURL"] == _REMOTE_TEMPLATE_URL data = json.loads(result.content) assert data["status"] == "CREATE_COMPLETE" @@ -514,7 +571,7 @@ async def test_create_stack_rollback_emits_failure_telemetry(self, tool: RosStac result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -557,7 +614,7 @@ async def test_create_stack_import_create_complete_is_terminal_success( result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -600,7 +657,7 @@ async def test_create_stack_import_create_rollback_complete_is_terminal_error( result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -651,7 +708,7 @@ def record_event(event_name: str, metadata: dict | None = None) -> None: result = await tool.execute( tool_input={ "action": "UpdateStack", - "params": {"StackId": "stack-123", "TemplateBody": "{}"}, + "params": {"StackId": "stack-123", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -707,7 +764,7 @@ async def test_update_stack_ignores_create_complete_from_previous_operation( result = await tool.execute( tool_input={ "action": "UpdateStack", - "params": {"StackId": "stack-123", "TemplateBody": "{}"}, + "params": {"StackId": "stack-123", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -747,7 +804,7 @@ async def test_update_stack_resource_error_with_stale_create_complete_is_not_ter result = await tool.execute( tool_input={ "action": "UpdateStack", - "params": {"StackId": "stack-123", "TemplateBody": "{}"}, + "params": {"StackId": "stack-123", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -793,7 +850,7 @@ def flaky_log_event(event_name: str, metadata: dict | None = None) -> None: result = await tool.execute( tool_input={ "action": "UpdateStack", - "params": {"StackId": "stack-123", "TemplateBody": "{}"}, + "params": {"StackId": "stack-123", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -804,7 +861,7 @@ def flaky_log_event(event_name: str, metadata: dict | None = None) -> None: @pytest.mark.asyncio async def test_update_stack_non_string_resource_type_does_not_prevent_api_call( - self, tool: RosStack, mock_credentials + self, tool: RosStack, mock_credentials, tmp_path ) -> None: mock_client = MagicMock() @@ -826,6 +883,8 @@ async def test_update_stack_non_string_resource_type_does_not_prevent_api_call( mock_client.list_stack_resources.return_value = list_resources_response template_body = json.dumps({"Resources": {"R": {"Type": 123}}}) + template_file = tmp_path / "update-non-string-resource-type.json" + template_file.write_text(template_body, encoding="utf-8") with ( patch("iac_code.tools.cloud.aliyun.ros_stack.RosClientFactory") as mock_factory, @@ -835,7 +894,7 @@ async def test_update_stack_non_string_resource_type_does_not_prevent_api_call( result = await tool.execute( tool_input={ "action": "UpdateStack", - "params": {"StackId": "stack-123", "TemplateBody": template_body}, + "params": {"StackId": "stack-123", "TemplateURL": str(template_file)}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -847,7 +906,7 @@ async def test_update_stack_non_string_resource_type_does_not_prevent_api_call( assert data["status"] == "UPDATE_COMPLETE" @pytest.mark.asyncio - async def test_update_stack_none_template_body_does_not_prevent_api_call( + async def test_update_stack_template_url_does_not_require_template_body( self, tool: RosStack, mock_credentials ) -> None: mock_client = MagicMock() @@ -877,7 +936,7 @@ async def test_update_stack_none_template_body_does_not_prevent_api_call( result = await tool.execute( tool_input={ "action": "UpdateStack", - "params": {"StackId": "stack-123", "TemplateBody": None}, + "params": {"StackId": "stack-123", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -886,7 +945,7 @@ async def test_update_stack_none_template_body_does_not_prevent_api_call( assert result.is_error is False mock_client.update_stack.assert_called_once() request = mock_client.update_stack.call_args.args[0] - assert request.template_body is None + assert request.to_map()["TemplateURL"] == _REMOTE_TEMPLATE_URL data = json.loads(result.content) assert data["status"] == "UPDATE_COMPLETE" @@ -925,7 +984,7 @@ async def test_update_stack_rollback_emits_failure_telemetry(self, tool: RosStac result = await tool.execute( tool_input={ "action": "UpdateStack", - "params": {"StackId": "stack-123", "TemplateBody": "{}"}, + "params": {"StackId": "stack-123", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -1145,10 +1204,10 @@ async def test_stale_create_or_update_context_is_not_consumed_by_delete_stack_te action_response.body.stack_id = "stack-123" if stale_action == "CreateStack": mock_client.create_stack.return_value = action_response - stale_params = {"StackName": "test", "TemplateBody": "{}"} + stale_params = {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL} else: mock_client.update_stack.return_value = action_response - stale_params = {"StackId": "stack-123", "TemplateBody": "{}"} + stale_params = {"StackId": "stack-123", "TemplateURL": _REMOTE_TEMPLATE_URL} delete_get_stack_response = MagicMock() delete_get_stack_response.body.to_map.return_value = { @@ -1278,7 +1337,7 @@ async def test_terminal_status_emits_deployment_telemetry_when_resource_polling_ result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -1325,7 +1384,7 @@ def flaky_log_event(event_name: str, metadata: dict | None = None) -> None: result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -1375,7 +1434,7 @@ def flaky_log_event(event_name: str, metadata: dict | None = None) -> None: result = await tool.execute( tool_input={ "action": "CreateStack", - "params": {"StackName": "test", "TemplateBody": "{}"}, + "params": {"StackName": "test", "TemplateURL": _REMOTE_TEMPLATE_URL}, "region_id": "cn-hangzhou", }, context=ToolContext(), @@ -1412,8 +1471,8 @@ async def test_continue_create_stack(self, stack): @pytest.mark.parametrize( ("action", "params", "sdk_method"), [ - ("CreateStack", {"StackName": "n", "TemplateBody": "{}"}, "create_stack"), - ("UpdateStack", {"StackId": "sx", "TemplateBody": "{}"}, "update_stack"), + ("CreateStack", {"StackName": "n", "TemplateURL": _REMOTE_TEMPLATE_URL}, "create_stack"), + ("UpdateStack", {"StackId": "sx", "TemplateURL": _REMOTE_TEMPLATE_URL}, "update_stack"), ("ContinueCreateStack", {"StackId": "sx"}, "continue_create_stack"), ("DeleteStack", {"StackId": "sx"}, "delete_stack"), ], @@ -1452,7 +1511,17 @@ async def test_template_url_local_file_read(self, stack, tmp_path): assert result == "stack-fake" @pytest.mark.asyncio - async def test_template_body_dict_to_json(self, stack): + async def test_template_body_dict_is_rejected_in_pipeline(self, stack): + with pytest.raises(ValueError, match="TemplateURL"): + await stack.call_action( + "CreateStack", + {"StackName": "n", "TemplateBody": {"ROSTemplateFormatVersion": "2015-09-01"}}, + "cn-hangzhou", + pipeline_mode=True, + ) + + @pytest.mark.asyncio + async def test_template_body_dict_to_json_outside_pipeline(self, stack): result = await stack.call_action( "CreateStack", {"StackName": "n", "TemplateBody": {"ROSTemplateFormatVersion": "2015-09-01"}}, @@ -1534,7 +1603,7 @@ async def test_create_stack_parameters_survive_hooks_for_typed_sdk(self, monkeyp "CreateStack", { "StackName": "n", - "TemplateBody": _MINIMAL_TEMPLATE_BODY, + "TemplateURL": _REMOTE_TEMPLATE_URL, "Parameters": [ {"ParameterKey": "VpcId", "ParameterValue": "vpc-123"}, {"ParameterKey": "ZoneId", "ParameterValue": "cn-hangzhou-h"}, @@ -1562,7 +1631,7 @@ async def test_create_stack_flat_parameters_are_restored_for_typed_sdk(self, mon "CreateStack", { "StackName": "n", - "TemplateBody": _MINIMAL_TEMPLATE_BODY, + "TemplateURL": _REMOTE_TEMPLATE_URL, "Parameters.1.ParameterKey": "VpcId", "Parameters.1.ParameterValue": "vpc-123", }, @@ -1587,7 +1656,7 @@ async def test_update_stack_flat_parameters_are_restored_for_typed_sdk(self, mon "UpdateStack", { "StackId": "stack-123", - "TemplateBody": _MINIMAL_TEMPLATE_BODY, + "TemplateURL": _REMOTE_TEMPLATE_URL, "Parameters.1.ParameterKey": "VpcId", "Parameters.1.ParameterValue": "vpc-123", }, diff --git a/tests/tools/test_tool_executor.py b/tests/tools/test_tool_executor.py index f87ffd62..4cfe73c3 100644 --- a/tests/tools/test_tool_executor.py +++ b/tests/tools/test_tool_executor.py @@ -226,6 +226,21 @@ async def execute(self, *, tool_input, context): assert tool.seen_context_queues == {"first": first_queue, "second": second_queue} assert tool._event_queue is None + async def test_pipeline_mode_is_preserved_in_derived_tool_context(self): + class ContextAwareTool(FakeReadTool): + async def execute(self, *, tool_input, context): + return ToolResult.success(str(context.pipeline_mode)) + + tool = ContextAwareTool() + registry = MagicMock() + registry.get = lambda name: tool + executor = ToolExecutor(registry=registry) + calls = [ToolCallRequest(id="a", name="read", input={})] + + results = await executor.execute_batch(calls, ToolContext(pipeline_mode=True)) + + assert results[0].content == "True" + class FakeStrictTool(Tool): @property From 858b5943993d507d534f5f0109b35018408b0008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 23 Jun 2026 13:03:10 +0800 Subject: [PATCH 18/59] docs: record fix-skill cherry-pick --- docs/batch/20260623-fix-skill-cherry-pick.md | 36 +++++++++ .../i18n/locales/de/LC_MESSAGES/messages.po | 77 ++++++++++++++++--- .../i18n/locales/es/LC_MESSAGES/messages.po | 75 ++++++++++++++++-- .../i18n/locales/fr/LC_MESSAGES/messages.po | 75 +++++++++++++++--- .../i18n/locales/ja/LC_MESSAGES/messages.po | 70 +++++++++++++++-- .../i18n/locales/pt/LC_MESSAGES/messages.po | 76 +++++++++++++++--- .../i18n/locales/zh/LC_MESSAGES/messages.po | 63 +++++++++++++-- 7 files changed, 424 insertions(+), 48 deletions(-) create mode 100644 docs/batch/20260623-fix-skill-cherry-pick.md diff --git a/docs/batch/20260623-fix-skill-cherry-pick.md b/docs/batch/20260623-fix-skill-cherry-pick.md new file mode 100644 index 00000000..6e32c0c9 --- /dev/null +++ b/docs/batch/20260623-fix-skill-cherry-pick.md @@ -0,0 +1,36 @@ +# 20260623 fix-skill cherry-pick + +## 背景 + +将 `/Users/ehzyo/open_repo/iac-code3/.worktrees/fix-skill` 最近 5 个提交合回主工作区 `/Users/ehzyo/open_repo/iac-code3` 的 `fix_pipeline` 分支。 + +## 合入提交 + +- `a41e479 fix: refine aliyun selling pipeline skills` +- `df374c2 feat: add pipeline surface prompt overrides` +- `18699f8 fix: enforce ROS stack completion guard` +- `7d17bd5 fix: require unique ROS stack names` +- `0174c39 fix: restrict ROS TemplateBody in pipeline mode` + +## 主要内容 + +- 对齐 selling pipeline 下 3 个阿里云技能的提示和评测,补充参数推荐、PreviewStack、参数透传、部署守卫等行为说明。 +- 为 `confirm_and_select` 增加 surface override 支持,使 REPL 和 A2A 可以使用不同 prompt;A2A 使用更薄的选择协议 prompt。 +- 增加部署完成守卫,要求部署成功结论必须和 `ros_stack` 成功工具结果匹配,避免无 stack_id 时直接提交成功结论。 +- 要求部署 StackName 带随机后缀,并在 cost 阶段的 PreviewStack 明确传入 StackName。 +- 在 pipeline 工具上下文中限制 ROS 模板调用使用 `TemplateURL`,但非 pipeline 模式仍兼容传统 `TemplateBody`。 + +## 冲突处理 + +- `pipeline.yaml` 中保留 `parameter_overrides` 和 `completion_guards`,没有恢复已经移除的静态 rollback 规则。 +- `pipeline_runner.py`、`deploying.py`、`test_terminal_ui_contract.py`、`test_step_executor.py` 合并了目标分支已有的 cleanup、用户输入、skill root 读权限等逻辑。 +- `session_index.py` 保留本地 `CLEANUP_PROMPT_METADATA_TYPE` 常量,避免 normal mode 导入 pipeline engine。 +- 本次 cherry-pick 没有遇到国际化文件冲突;后续按验证结果执行 `make translate`,将新增 msgid 同步到各语言 `messages.po`,没有手工删除既有词条。 + +## 验证 + +按要求执行: + +- `make lint` +- `make format` +- `make test` diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 9ccbf79e..a82a4226 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -1817,6 +1817,7 @@ msgid "Input schema" msgstr "Eingabeschema" #: src/iac_code/commands/prompt.py +#: src/iac_code/pipeline/engine/complete_step_tool.py msgid "tool" msgstr "Tool" @@ -2456,23 +2457,52 @@ msgstr "" "{message} complete_step.conclusion muss eines dieser Felder enthalten: " "{fields}." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A successful tool result is required before completing the current step." +msgstr "" +"Vor dem Abschluss des aktuellen Schritts ist ein erfolgreiches Tool-" +"Ergebnis erforderlich." + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format -msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." msgstr "" -"Die Anzahl der Kandidaten darf {limit} nicht überschreiten; {count} " -"wurden übermittelt." +"{message} complete_step.conclusion.{field} muss dem Ergebniswert {value} " +"von {tool} entsprechen." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " with status {statuses}" +msgstr " mit Status {statuses}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " and is_success={expected}" +msgstr " und is_success={expected}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " one of {actions}" +msgstr " mit einer der Aktionen {actions}" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"Rollback target count cannot exceed {limit}; there are {count}. Ask the " -"user for help or narrow the rollback targets before calling " -"complete_step." +"{message} Call {tool}{action} first and wait for a successful " +"result{status_hint}{success_hint}." msgstr "" -"Die Anzahl der Rücksetzungsziele darf {limit} nicht überschreiten; es " -"gibt {count}. Bitten Sie den Benutzer um Hilfe oder grenzen Sie die Ziele" -" ein, bevor Sie complete_step aufrufen." +"{message} Rufen Sie zuerst {tool}{action} auf und warten Sie auf ein " +"erfolgreiches Ergebnis{status_hint}{success_hint}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "das erforderliche Tool" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2484,6 +2514,24 @@ msgstr "" "Schließen Sie den aktuellen Schritt ab oder bitten Sie den Benutzer um " "Hilfe." +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgstr "" +"Die Anzahl der Kandidaten darf {limit} nicht überschreiten; {count} " +"wurden übermittelt." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"Rollback target count cannot exceed {limit}; there are {count}. Ask the " +"user for help or narrow the rollback targets before calling " +"complete_step." +msgstr "" +"Die Anzahl der Rücksetzungsziele darf {limit} nicht überschreiten; es " +"gibt {count}. Bitten Sie den Benutzer um Hilfe oder grenzen Sie die Ziele" +" ein, bevor Sie complete_step aufrufen." + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "Schema validation failed after {attempts} attempts: {error}" @@ -3498,6 +3546,17 @@ msgstr "ROS Stack" msgid "CloudStackInstances" msgstr "CloudStackInstances" +#: src/iac_code/tools/cloud/aliyun/template_source.py +msgid "" +"ROS template calls must use TemplateURL instead of TemplateBody. Save the" +" template to a file and pass params.TemplateURL, for example a local file" +" path or OSS/HTTP URL." +msgstr "" +"ROS-Vorlagenaufrufe müssen TemplateURL statt TemplateBody verwenden. " +"Speichern Sie die Vorlage in einer Datei und übergeben Sie " +"params.TemplateURL, zum Beispiel einen lokalen Dateipfad oder eine OSS" +"/HTTP-URL." + #: src/iac_code/tools/cloud/aliyun/hooks/ros_validate.py #, python-brace-format msgid "Template YAML syntax error: {}" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index fc93f4ca..f17c4f88 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -1820,6 +1820,7 @@ msgid "Input schema" msgstr "Esquema de entrada" #: src/iac_code/commands/prompt.py +#: src/iac_code/pipeline/engine/complete_step_tool.py msgid "tool" msgstr "herramienta" @@ -2453,21 +2454,52 @@ msgstr "" "{message} complete_step.conclusion debe incluir uno de estos campos: " "{fields}." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A successful tool result is required before completing the current step." +msgstr "" +"Se requiere un resultado de herramienta correcto antes de completar el " +"paso actual." + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format -msgid "Candidate count cannot exceed {limit}; {count} were submitted." -msgstr "La cantidad de candidatos no puede superar {limit}; se enviaron {count}." +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "" +"{message} complete_step.conclusion.{field} debe coincidir con el valor " +"{value} del resultado de {tool}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " with status {statuses}" +msgstr " con estado {statuses}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " and is_success={expected}" +msgstr " e is_success={expected}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " one of {actions}" +msgstr " con una de las acciones {actions}" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"Rollback target count cannot exceed {limit}; there are {count}. Ask the " -"user for help or narrow the rollback targets before calling " -"complete_step." +"{message} Call {tool}{action} first and wait for a successful " +"result{status_hint}{success_hint}." msgstr "" -"La cantidad de destinos de reversión no puede superar {limit}; hay " -"{count}. Pide ayuda al usuario o reduce los destinos antes de llamar a " -"complete_step." +"{message} Llama primero a {tool}{action} y espera un resultado " +"correcto{status_hint}{success_hint}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "la herramienta requerida" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2478,6 +2510,22 @@ msgstr "" "La cantidad de reversiones no puede superar {max_rollbacks}. Completa el " "paso actual o pide ayuda al usuario." +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgstr "La cantidad de candidatos no puede superar {limit}; se enviaron {count}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"Rollback target count cannot exceed {limit}; there are {count}. Ask the " +"user for help or narrow the rollback targets before calling " +"complete_step." +msgstr "" +"La cantidad de destinos de reversión no puede superar {limit}; hay " +"{count}. Pide ayuda al usuario o reduce los destinos antes de llamar a " +"complete_step." + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "Schema validation failed after {attempts} attempts: {error}" @@ -3488,6 +3536,17 @@ msgstr "ROS Stack" msgid "CloudStackInstances" msgstr "CloudStackInstances" +#: src/iac_code/tools/cloud/aliyun/template_source.py +msgid "" +"ROS template calls must use TemplateURL instead of TemplateBody. Save the" +" template to a file and pass params.TemplateURL, for example a local file" +" path or OSS/HTTP URL." +msgstr "" +"Las llamadas a plantillas ROS deben usar TemplateURL en lugar de " +"TemplateBody. Guarda la plantilla en un archivo y pasa " +"params.TemplateURL, por ejemplo una ruta de archivo local o una URL " +"OSS/HTTP." + #: src/iac_code/tools/cloud/aliyun/hooks/ros_validate.py #, python-brace-format msgid "Template YAML syntax error: {}" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index bf5781cc..88e73ef8 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -1823,6 +1823,7 @@ msgid "Input schema" msgstr "Schéma d’entrée" #: src/iac_code/commands/prompt.py +#: src/iac_code/pipeline/engine/complete_step_tool.py msgid "tool" msgstr "outil" @@ -2455,23 +2456,50 @@ msgstr "" "{message} complete_step.conclusion doit inclure l’un de ces champs : " "{fields}." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A successful tool result is required before completing the current step." +msgstr "Un résultat d’outil réussi est requis avant de terminer l’étape actuelle." + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format -msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." msgstr "" -"Le nombre de candidats ne peut pas dépasser {limit} ; {count} ont été " -"soumis." +"{message} complete_step.conclusion.{field} doit correspondre à la valeur " +"de résultat {value} de {tool}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " with status {statuses}" +msgstr " avec le statut {statuses}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " and is_success={expected}" +msgstr " et is_success={expected}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " one of {actions}" +msgstr " avec l’une des actions {actions}" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"Rollback target count cannot exceed {limit}; there are {count}. Ask the " -"user for help or narrow the rollback targets before calling " -"complete_step." +"{message} Call {tool}{action} first and wait for a successful " +"result{status_hint}{success_hint}." msgstr "" -"Le nombre de cibles de retour arrière ne peut pas dépasser {limit} ; il y" -" en a {count}. Demandez l’aide de l’utilisateur ou réduisez les cibles " -"avant d’appeler complete_step." +"{message} Appelez d’abord {tool}{action} et attendez un résultat " +"réussi{status_hint}{success_hint}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "l’outil requis" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2482,6 +2510,24 @@ msgstr "" "Le nombre de retours arrière ne peut pas dépasser {max_rollbacks}. " "Terminez l’étape actuelle ou demandez l’aide de l’utilisateur." +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgstr "" +"Le nombre de candidats ne peut pas dépasser {limit} ; {count} ont été " +"soumis." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"Rollback target count cannot exceed {limit}; there are {count}. Ask the " +"user for help or narrow the rollback targets before calling " +"complete_step." +msgstr "" +"Le nombre de cibles de retour arrière ne peut pas dépasser {limit} ; il y" +" en a {count}. Demandez l’aide de l’utilisateur ou réduisez les cibles " +"avant d’appeler complete_step." + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "Schema validation failed after {attempts} attempts: {error}" @@ -3493,6 +3539,17 @@ msgstr "ROS Stack" msgid "CloudStackInstances" msgstr "CloudStackInstances" +#: src/iac_code/tools/cloud/aliyun/template_source.py +msgid "" +"ROS template calls must use TemplateURL instead of TemplateBody. Save the" +" template to a file and pass params.TemplateURL, for example a local file" +" path or OSS/HTTP URL." +msgstr "" +"Les appels de modèle ROS doivent utiliser TemplateURL au lieu de " +"TemplateBody. Enregistrez le modèle dans un fichier et transmettez " +"params.TemplateURL, par exemple un chemin de fichier local ou une URL " +"OSS/HTTP." + #: src/iac_code/tools/cloud/aliyun/hooks/ros_validate.py #, python-brace-format msgid "Template YAML syntax error: {}" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 56bda993..3d421449 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -1765,6 +1765,7 @@ msgid "Input schema" msgstr "入力スキーマ" #: src/iac_code/commands/prompt.py +#: src/iac_code/pipeline/engine/complete_step_tool.py msgid "tool" msgstr "ツール" @@ -2349,20 +2350,50 @@ msgid "" "{fields}." msgstr "{message} complete_step.conclusion には次のいずれかのフィールドが必要です: {fields}。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A successful tool result is required before completing the current step." +msgstr "現在のステップを完了する前に、成功したツール結果が必要です。" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format -msgid "Candidate count cannot exceed {limit}; {count} were submitted." -msgstr "候補数は {limit} を超えられません。{count} 件が送信されました。" +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "" +"{message} complete_step.conclusion.{field} は {tool} の結果値 {value} " +"と一致する必要があります。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " with status {statuses}" +msgstr "(ステータス: {statuses})" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " and is_success={expected}" +msgstr "、かつ is_success={expected}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " one of {actions}" +msgstr "(アクションは {actions} のいずれか)" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"Rollback target count cannot exceed {limit}; there are {count}. Ask the " -"user for help or narrow the rollback targets before calling " -"complete_step." +"{message} Call {tool}{action} first and wait for a successful " +"result{status_hint}{success_hint}." msgstr "" -"ロールバック対象数は {limit} を超えられません。現在 {count} 件あります。complete_step " -"を呼ぶ前にユーザーに支援を求めるか、対象を絞ってください。" +"{message} まず {tool}{action} " +"を呼び出し、成功した結果{status_hint}{success_hint}を待ってください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "必須ツール" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2371,6 +2402,21 @@ msgid "" "or ask the user for help." msgstr "ロールバック回数は {max_rollbacks} を超えられません。現在のステップを完了するか、ユーザーに支援を求めてください。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgstr "候補数は {limit} を超えられません。{count} 件が送信されました。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"Rollback target count cannot exceed {limit}; there are {count}. Ask the " +"user for help or narrow the rollback targets before calling " +"complete_step." +msgstr "" +"ロールバック対象数は {limit} を超えられません。現在 {count} 件あります。complete_step " +"を呼ぶ前にユーザーに支援を求めるか、対象を絞ってください。" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "Schema validation failed after {attempts} attempts: {error}" @@ -3347,6 +3393,16 @@ msgstr "ROS スタック" msgid "CloudStackInstances" msgstr "CloudStackInstances" +#: src/iac_code/tools/cloud/aliyun/template_source.py +msgid "" +"ROS template calls must use TemplateURL instead of TemplateBody. Save the" +" template to a file and pass params.TemplateURL, for example a local file" +" path or OSS/HTTP URL." +msgstr "" +"ROS テンプレート呼び出しでは TemplateBody ではなく TemplateURL " +"を使用する必要があります。テンプレートをファイルに保存し、ローカルファイルパスまたは OSS/HTTP URL などを " +"params.TemplateURL として渡してください。" + #: src/iac_code/tools/cloud/aliyun/hooks/ros_validate.py #, python-brace-format msgid "Template YAML syntax error: {}" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 96181552..7594b9bc 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -1811,6 +1811,7 @@ msgid "Input schema" msgstr "Esquema de entrada" #: src/iac_code/commands/prompt.py +#: src/iac_code/pipeline/engine/complete_step_tool.py msgid "tool" msgstr "ferramenta" @@ -2438,23 +2439,52 @@ msgstr "" "{message} complete_step.conclusion deve incluir um destes campos: " "{fields}." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A successful tool result is required before completing the current step." +msgstr "" +"É necessário um resultado de ferramenta bem-sucedido antes de concluir a " +"etapa atual." + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format -msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." msgstr "" -"A quantidade de candidatos não pode exceder {limit}; {count} foram " -"enviados." +"{message} complete_step.conclusion.{field} deve corresponder ao valor de " +"resultado {value} de {tool}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " with status {statuses}" +msgstr " com status {statuses}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " and is_success={expected}" +msgstr " e is_success={expected}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " one of {actions}" +msgstr " com uma das ações {actions}" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"Rollback target count cannot exceed {limit}; there are {count}. Ask the " -"user for help or narrow the rollback targets before calling " -"complete_step." +"{message} Call {tool}{action} first and wait for a successful " +"result{status_hint}{success_hint}." msgstr "" -"A quantidade de destinos de reversão não pode exceder {limit}; há " -"{count}. Peça ajuda ao usuário ou reduza os destinos antes de chamar " -"complete_step." +"{message} Chame {tool}{action} primeiro e aguarde um resultado bem-" +"sucedido{status_hint}{success_hint}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "a ferramenta obrigatória" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2465,6 +2495,24 @@ msgstr "" "A quantidade de reversões não pode exceder {max_rollbacks}. Conclua a " "etapa atual ou peça ajuda ao usuário." +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgstr "" +"A quantidade de candidatos não pode exceder {limit}; {count} foram " +"enviados." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"Rollback target count cannot exceed {limit}; there are {count}. Ask the " +"user for help or narrow the rollback targets before calling " +"complete_step." +msgstr "" +"A quantidade de destinos de reversão não pode exceder {limit}; há " +"{count}. Peça ajuda ao usuário ou reduza os destinos antes de chamar " +"complete_step." + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "Schema validation failed after {attempts} attempts: {error}" @@ -3471,6 +3519,16 @@ msgstr "ROS Stack" msgid "CloudStackInstances" msgstr "CloudStackInstances" +#: src/iac_code/tools/cloud/aliyun/template_source.py +msgid "" +"ROS template calls must use TemplateURL instead of TemplateBody. Save the" +" template to a file and pass params.TemplateURL, for example a local file" +" path or OSS/HTTP URL." +msgstr "" +"As chamadas de modelo ROS devem usar TemplateURL em vez de TemplateBody. " +"Salve o modelo em um arquivo e passe params.TemplateURL, por exemplo um " +"caminho de arquivo local ou uma URL OSS/HTTP." + #: src/iac_code/tools/cloud/aliyun/hooks/ros_validate.py #, python-brace-format msgid "Template YAML syntax error: {}" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 4efa1cf3..1ca2031a 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -1751,6 +1751,7 @@ msgid "Input schema" msgstr "输入 schema" #: src/iac_code/commands/prompt.py +#: src/iac_code/pipeline/engine/complete_step_tool.py msgid "tool" msgstr "工具" @@ -2315,18 +2316,46 @@ msgid "" "{fields}." msgstr "{message} complete_step.conclusion 必须包含以下字段之一: {fields}。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A successful tool result is required before completing the current step." +msgstr "完成当前步骤前需要成功的工具结果。" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format -msgid "Candidate count cannot exceed {limit}; {count} were submitted." -msgstr "候选方案数量不能超过 {limit} 个,当前提交了 {count} 个。" +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "{message} complete_step.conclusion.{field} 必须与 {tool} 结果值 {value} 匹配。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " with status {statuses}" +msgstr ",状态为 {statuses}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " and is_success={expected}" +msgstr ",且 is_success={expected}" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid " one of {actions}" +msgstr ",动作为 {actions} 之一" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"Rollback target count cannot exceed {limit}; there are {count}. Ask the " -"user for help or narrow the rollback targets before calling " -"complete_step." -msgstr "可回滚目标数量不能超过 {limit} 个,当前有 {count} 个。请请求用户介入或收窄回滚目标后再调用 complete_step。" +"{message} Call {tool}{action} first and wait for a successful " +"result{status_hint}{success_hint}." +msgstr "{message} 请先调用 {tool}{action},并等待成功结果{status_hint}{success_hint}。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "必需工具" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2335,6 +2364,19 @@ msgid "" "or ask the user for help." msgstr "回滚次数不能超过 {max_rollbacks} 次。请完成当前步骤或请求用户介入。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "Candidate count cannot exceed {limit}; {count} were submitted." +msgstr "候选方案数量不能超过 {limit} 个,当前提交了 {count} 个。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"Rollback target count cannot exceed {limit}; there are {count}. Ask the " +"user for help or narrow the rollback targets before calling " +"complete_step." +msgstr "可回滚目标数量不能超过 {limit} 个,当前有 {count} 个。请请求用户介入或收窄回滚目标后再调用 complete_step。" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "Schema validation failed after {attempts} attempts: {error}" @@ -3297,6 +3339,15 @@ msgstr "ROS 资源栈" msgid "CloudStackInstances" msgstr "云资源栈实例" +#: src/iac_code/tools/cloud/aliyun/template_source.py +msgid "" +"ROS template calls must use TemplateURL instead of TemplateBody. Save the" +" template to a file and pass params.TemplateURL, for example a local file" +" path or OSS/HTTP URL." +msgstr "" +"ROS 模板调用必须使用 TemplateURL,而不能使用 TemplateBody。请将模板保存到文件,并传入 " +"params.TemplateURL,例如本地文件路径或 OSS/HTTP URL。" + #: src/iac_code/tools/cloud/aliyun/hooks/ros_validate.py #, python-brace-format msgid "Template YAML syntax error: {}" From 5b2d5bf0c1b6675a0b3e1b33ee08a196dc022bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 23 Jun 2026 13:49:52 +0800 Subject: [PATCH 19/59] feat: add selling pipeline console --- .../2026-06-18-selling-pipeline-console.md | 1339 +++++ ...6-06-18-selling-pipeline-console-design.md | 171 + scripts/README.md | 5 + scripts/a2a/selling_console.py | 241 + scripts/a2a/selling_console_web/README.md | 32 + scripts/a2a/selling_console_web/app.js | 4327 +++++++++++++++ .../selling-pipeline-progress-options.html | 1655 ++++++ scripts/a2a/selling_console_web/index.html | 167 + scripts/a2a/selling_console_web/styles.css | 2371 ++++++++ src/iac_code/a2a/pipeline_events.py | 6 +- tests/a2a/test_pipeline_events.py | 42 + tests/a2a/test_selling_console_frontend.py | 4861 +++++++++++++++++ tests/a2a/test_selling_console_script.py | 992 ++++ 13 files changed, 16206 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-selling-pipeline-console.md create mode 100644 docs/superpowers/specs/2026-06-18-selling-pipeline-console-design.md create mode 100644 scripts/a2a/selling_console.py create mode 100644 scripts/a2a/selling_console_web/README.md create mode 100644 scripts/a2a/selling_console_web/app.js create mode 100644 scripts/a2a/selling_console_web/design/selling-pipeline-progress-options.html create mode 100644 scripts/a2a/selling_console_web/index.html create mode 100644 scripts/a2a/selling_console_web/styles.css create mode 100644 tests/a2a/test_selling_console_frontend.py create mode 100644 tests/a2a/test_selling_console_script.py diff --git a/docs/superpowers/plans/2026-06-18-selling-pipeline-console.md b/docs/superpowers/plans/2026-06-18-selling-pipeline-console.md new file mode 100644 index 00000000..2e8b1bdc --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-selling-pipeline-console.md @@ -0,0 +1,1339 @@ +# Selling Pipeline Console Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a local `scripts/` website that turns A2A selling pipeline streams into an Alibaba Cloud-style purchase-plan console. + +**Architecture:** Add a self-contained Python HTTP server at `scripts/a2a/selling_console.py` that reuses `scripts/a2a/debugger.py` protocol helpers for A2A proxying. Serve static HTML/CSS/JS from `scripts/a2a/selling_console_web/`; the frontend owns the selling-specific reducer, visual shell, candidate selection, pending input, and normal handoff behavior. + +**Tech Stack:** Python stdlib `http.server`, pytest, browser-native HTML/CSS/JavaScript, existing `uv`/Makefile workflow. + +--- + +## File Structure + +- Create `scripts/a2a/selling_console.py` + - Parse CLI arguments. + - Render the static shell with safe JSON defaults. + - Serve static files from `scripts/a2a/selling_console_web/`. + - Proxy A2A health, streaming, task get, task cancel, and pipeline state through `scripts.a2a.debugger` helpers. +- Create `scripts/a2a/selling_console_web/index.html` + - Alibaba Cloud-style top navigation. + - Left assistant workflow panel. + - Right purchase plan area. + - Diagnostic drawer container. +- Create `scripts/a2a/selling_console_web/styles.css` + - Screenshot-matching layout, spacing, colors, cards, responsive behavior. +- Create `scripts/a2a/selling_console_web/app.js` + - Pure reducer helpers exported under `window.SellingConsoleReducers`. + - DOM controller for health, stream, state fetch, cancel, candidate selection, pending input, debug drawer. +- Create `tests/a2a/test_selling_console_script.py` + - Python server, routes, proxy, escaping, static safety, and JavaScript syntax checks. +- Create `tests/a2a/test_selling_console_frontend.py` + - Node-backed reducer behavior tests. Skip when Node is unavailable. +- Modify `scripts/README.md` + - Add the new local selling console script to the scripts table and usage commands. + +## Task 1: Python Server Contract + +**Files:** +- Create: `scripts/a2a/selling_console.py` +- Create: `tests/a2a/test_selling_console_script.py` + +- [ ] **Step 1: Write failing server tests** + +Create `tests/a2a/test_selling_console_script.py` with: + +```python +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "a2a" / "selling_console.py" + + +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) + + +@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 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 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 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 " + + + +

阿里云
+

您的购买方案

+ + +``` + +```css +.topbar { height: 56px; } +``` + +```javascript +(function () { + window.SellingConsoleReducers = {}; +})(); +``` + +Implement `create_server()` routes: + +```python +def create_server(config: SellingConsoleConfig) -> ThreadingHTTPServer: + class SellingConsoleHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + return None + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/": + _send_text(self, render_index_html(config), content_type="text/html") + return + if parsed.path == "/api/health": + status, body = a2a_debugger._health_response(a2a_debugger._query_params(self.path).get("serverUrl", "")) + _send_json(self, status, body) + return + _send_static(self, parsed.path) + return ThreadingHTTPServer((config.host, config.port), SellingConsoleHandler) +``` + +- [ ] **Step 4: Run tests and verify GREEN for server basics** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py -v +``` + +Expected: PASS for parser, help, index, escaping, static serving, and health route. + +- [ ] **Step 5: Commit Task 1** + +Run: + +```bash +git add scripts/a2a/selling_console.py scripts/a2a/selling_console_web/index.html scripts/a2a/selling_console_web/styles.css scripts/a2a/selling_console_web/app.js tests/a2a/test_selling_console_script.py +PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add selling pipeline console server" +``` + +## Task 2: Complete A2A Proxy Routes + +**Files:** +- Modify: `scripts/a2a/selling_console.py` +- Modify: `tests/a2a/test_selling_console_script.py` + +- [ ] **Step 1: Add failing proxy tests** + +Append tests for `/api/pipeline/state`, `/api/task/get`, `/api/task/cancel`, and `/api/message/stream`. Use the same `JsonTargetHandler` plus a new SSE target: + +```python +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","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) +``` + +Add assertions: + +```python +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 + + +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 + 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 + 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"}} +``` + +Add `post_raw()` helper to return status, text, and content type. + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py -v +``` + +Expected: FAIL on missing routes or incorrect proxy behavior. + +- [ ] **Step 3: Implement proxy routes** + +In `selling_console.py`, delegate to debugger helpers: + +```python +if parsed.path == "/api/pipeline/state": + status, body = a2a_debugger._pipeline_state_response(a2a_debugger._query_params(self.path)) + _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)) + _send_json(self, status, body) + return +``` + +For POST: + +```python +if parsed.path == "/api/message/stream": + body = _read_json_body(self) + server_url, payload = a2a_debugger._message_stream_body(body) + with a2a_debugger._open_sse_stream(server_url, payload) as response: + self.send_response(response.status) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.end_headers() + for line in response: + self.wfile.write(line) + self.wfile.flush() + return +if parsed.path == "/api/task/cancel": + body = _read_json_body(self) + status, response_body = a2a_debugger._task_cancel_response(body) + _send_json(self, status, response_body) + return +``` + +Wrap `ValueError` as `400` JSON and `HTTPError`, `URLError`, `TimeoutError`, `OSError` as proxy errors using debugger semantics. + +- [ ] **Step 4: Run tests and verify GREEN** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 2** + +Run: + +```bash +git add scripts/a2a/selling_console.py tests/a2a/test_selling_console_script.py +PATH="$HOME/.local/bin:$PATH" git commit -m "feat: proxy A2A routes for selling console" +``` + +## Task 3: Frontend Reducer Tests + +**Files:** +- Create: `tests/a2a/test_selling_console_frontend.py` +- Modify: `scripts/a2a/selling_console_web/app.js` + +- [ ] **Step 1: Write failing reducer tests** + +Create `tests/a2a/test_selling_console_frontend.py`: + +```python +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +APP_JS = Path(__file__).resolve().parents[2] / "scripts" / "a2a" / "selling_console_web" / "app.js" + + +def run_node_script(source: str) -> dict: + try: + result = subprocess.run(["node", "-e", source], capture_output=True, text=True, check=False) + except FileNotFoundError: + pytest.skip("node is not installed") + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +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 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"}); + 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: state.pipelineTaskId, + contextId: state.contextId, + sequence: state.lastSequence, + architectureStatus: state.steps.architecture_planning.status + }; + """ + ) + + assert output == { + "taskId": "task-1", + "contextId": "ctx-1", + "sequence": 3, + "architectureStatus": "completed", + } + + +def test_reducer_collects_candidate_details_from_tool_display() -> None: + output = reducer_harness( + """ + const state = reducers.createInitialState({}); + 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: state.candidates.length, + firstName: state.candidates[0].name, + firstCost: state.candidates[0].totalMonthlyCost, + pendingPrompt: state.pendingInput.prompt + }; + """ + ) + + assert output == { + "candidateCount": 1, + "firstName": "ECS 经典网络方案", + "firstCost": "¥33.89/月", + "pendingPrompt": "请选择方案", + } + + +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"; + 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: state.normalHandoffReady, + activeTaskId: state.activeTaskId, + contextId: state.contextId + }; + """ + ) + + assert output == { + "normalHandoffReady": True, + "activeTaskId": "", + "contextId": "ctx-1", + } +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_frontend.py -v +``` + +Expected: FAIL because `createInitialState` and `reducePipelinePayload` are not implemented. + +- [ ] **Step 3: Implement reducer helpers** + +In `app.js`, expose pure helpers: + +```javascript +(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: "确认部署" + }; + + function createInitialState(defaults = {}) { + const steps = {}; + STEP_ORDER.forEach((id) => { + steps[id] = {id, label: STEP_LABELS[id], status: "pending", events: []}; + }); + return { + defaults, + serverUrl: defaults.serverUrl || "", + cwd: defaults.cwd || "", + contextId: "", + pipelineTaskId: "", + activeTaskId: "", + lastSequence: 0, + status: "idle", + normalHandoffReady: false, + steps, + candidates: [], + selectedCandidateIndex: null, + pendingInput: null, + permission: null, + diagnostics: {requests: [], sse: [], snapshots: []} + }; + } +``` + +Implement: + +- `extractPipelineEnvelope(payload)` tolerant of metadata and snapshot wrappers. +- `normalizeStepId(step)` mapping candidate sub-step events to `evaluate_candidates`. +- `upsertCandidate(state, candidate)`. +- `reducePipelinePayload(state, payload)`. +- `candidateFromDisplayItem(item)`. +- `pendingInputFromSnapshot(snapshot)`. + +- [ ] **Step 4: Run reducer tests and verify GREEN** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_frontend.py -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 3** + +Run: + +```bash +git add scripts/a2a/selling_console_web/app.js tests/a2a/test_selling_console_frontend.py +PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add selling console frontend reducer" +``` + +## Task 4: Screenshot-Matching Static UI + +**Files:** +- Modify: `scripts/a2a/selling_console_web/index.html` +- Modify: `scripts/a2a/selling_console_web/styles.css` +- Modify: `scripts/a2a/selling_console_web/app.js` +- Modify: `tests/a2a/test_selling_console_script.py` + +- [ ] **Step 1: Add failing static UI contract test** + +Append: + +```python +def test_index_html_contains_screenshot_layout_regions() -> 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="/workspace/demo", + ) + ) + + for expected in [ + 'class="topbar"', + 'id="workflow-panel"', + 'id="plans-grid"', + 'id="composer-input"', + 'id="send-button"', + 'id="deep-think-button"', + 'id="debug-drawer"', + "需求理解", + "架构规划", + "方案评估", + "方案选择", + "您的购买方案", + "内容由 AI 生成,方案与价格仅供参考", + ]: + assert expected in html +``` + +Add CSS contract: + +```python +def test_styles_define_console_layout_tokens() -> None: + css = (SCRIPT_PATH.parent / "selling_console_web" / "styles.css").read_text(encoding="utf-8") + + for expected in [ + "--aliyun-orange", + ".console-shell", + ".workflow-panel", + ".plan-card", + ".price", + ".utility-rail", + "@media (max-width: 980px)", + ]: + assert expected in css +``` + +Add JS syntax check: + +```python +def test_frontend_javascript_is_syntax_valid() -> None: + app_js = SCRIPT_PATH.parent / "selling_console_web" / "app.js" + try: + result = subprocess.run(["node", "--check", str(app_js)], capture_output=True, text=True, check=False) + except FileNotFoundError: + pytest.skip("node is not installed") + + assert result.returncode == 0, result.stderr +``` + +- [ ] **Step 2: Run static UI tests and verify RED** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py::test_index_html_contains_screenshot_layout_regions tests/a2a/test_selling_console_script.py::test_styles_define_console_layout_tokens tests/a2a/test_selling_console_script.py::test_frontend_javascript_is_syntax_valid -v +``` + +Expected: FAIL on missing layout regions and CSS tokens. + +- [ ] **Step 3: Implement HTML shell** + +Replace the stub HTML with: + +```html + +
+
+ +
+ +
+
+ +
+ + + + +
+
+

内容由 AI 生成,方案与价格仅供参考,请以实际部署结果为准。

+
+
+
+

您的购买方案

+
+ + + + + +
+
+
+
+ 调试信息 +
+
+
+ +
+ +``` + +- [ ] **Step 4: Implement screenshot CSS** + +Set desktop dimensions close to the screenshot: + +```css +:root { + --aliyun-orange: #ff6a00; + --accent-blue: #4f7cff; + --success-green: #46c22f; + --text-strong: #1f2329; + --text-muted: #858b99; + --line: #d9e2ef; + --panel: #ffffff; + --soft-bg: #f7faff; +} + +body { + margin: 0; + color: var(--text-strong); + background: #fff; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.topbar { + display: grid; + grid-template-columns: 56px 132px auto auto auto minmax(280px, 1fr) auto auto; + align-items: center; + gap: 10px; + height: 64px; + padding: 0 18px; + border-bottom: 1px solid #edf1f7; + box-shadow: 0 2px 14px rgba(31, 35, 41, 0.08); +} + +.console-shell { + display: grid; + grid-template-columns: 48px minmax(460px, 640px) minmax(520px, 1fr) 64px; + min-height: calc(100vh - 64px); +} + +.workflow-panel { + position: relative; + padding: 42px 22px 20px 16px; + border-right: 1px solid #e7edf5; + background: + radial-gradient(circle at 10% 100%, rgba(237, 244, 255, 0.95), transparent 38%), + radial-gradient(circle at 90% 100%, rgba(255, 238, 246, 0.75), transparent 34%), + #fff; +} + +.plan-card { + min-height: 260px; + border: 1px solid #dde5f0; + border-radius: 8px; + padding: 28px; + background: #fff; +} + +.price { + color: var(--aliyun-orange); + font-size: 34px; + font-weight: 800; +} + +@media (max-width: 980px) { + .topbar { grid-template-columns: 48px 120px 1fr; } + .topbar .nav-pill, + .topbar .top-links, + .topbar .user-menu { display: none; } + .console-shell { grid-template-columns: 1fr; } + .assistant-rail, + .utility-rail { display: none; } + .workflow-panel { border-right: 0; } + .plans-grid { grid-template-columns: 1fr; } +} +``` + +- [ ] **Step 5: Run static UI tests and verify GREEN** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py::test_index_html_contains_screenshot_layout_regions tests/a2a/test_selling_console_script.py::test_styles_define_console_layout_tokens tests/a2a/test_selling_console_script.py::test_frontend_javascript_is_syntax_valid -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 4** + +Run: + +```bash +git add scripts/a2a/selling_console_web/index.html scripts/a2a/selling_console_web/styles.css scripts/a2a/selling_console_web/app.js tests/a2a/test_selling_console_script.py +PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add selling console visual shell" +``` + +## Task 5: DOM Controller and Complete Interaction + +**Files:** +- Modify: `scripts/a2a/selling_console_web/app.js` +- Modify: `tests/a2a/test_selling_console_frontend.py` + +- [ ] **Step 1: Add failing interaction reducer tests** + +Append frontend tests for composer payload and candidate selection: + +```python +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"; + return reducers.buildStreamPayload(state, "继续部署"); + """ + ) + + assert output == { + "serverUrl": "http://server", + "cwd": "/workspace", + "contextId": "ctx-1", + "taskId": "active-task", + "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} + ]; + reducers.selectCandidate(state, 1); + return { + selected: state.selectedCandidateIndex, + prompt: reducers.promptForSelectedCandidate(state) + }; + """ + ) + + assert output == {"selected": 1, "prompt": "选择方案1"} +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_frontend.py -v +``` + +Expected: FAIL on missing `buildStreamPayload`, `selectCandidate`, or `promptForSelectedCandidate`. + +- [ ] **Step 3: Implement DOM controller and remaining pure helpers** + +Add: + +```javascript +function buildStreamPayload(state, prompt) { + const taskId = state.normalHandoffReady ? "" : state.activeTaskId || state.pipelineTaskId || ""; + return { + serverUrl: state.serverUrl, + cwd: state.cwd, + contextId: state.contextId, + taskId, + prompt + }; +} + +function selectCandidate(state, candidateIndex) { + state.selectedCandidateIndex = Number(candidateIndex); +} + +function promptForSelectedCandidate(state) { + if (state.selectedCandidateIndex === null || state.selectedCandidateIndex === undefined) { + return ""; + } + return `选择方案${state.selectedCandidateIndex}`; +} +``` + +Add DOM functions: + +- `init()` reads `window.SELLING_CONSOLE_DEFAULTS`, creates state, binds buttons, renders empty state. +- `renderSteps()` creates workflow cards and candidate radio cards. +- `renderPlans()` creates right-side plan cards. +- `sendComposerMessage()` validates input, builds payload, streams SSE, and stops on `input_required`. +- `healthCheck()`, `fetchState()`, `cancelTask()`. +- `appendDiagnostic(kind, value)`, `renderDebug()`. +- `window.SellingConsoleDebug.loadDemoCandidates()` for browser verification without a live A2A server. It should inject two candidates matching the screenshot copy and re-render the workflow and plan cards. + +Use only `textContent`, `createElement`, and `setAttribute` for dynamic content. Avoid assigning user-controlled values to `innerHTML`. + +- [ ] **Step 4: Run frontend tests and syntax check** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_frontend.py tests/a2a/test_selling_console_script.py::test_frontend_javascript_is_syntax_valid -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 5** + +Run: + +```bash +git add scripts/a2a/selling_console_web/app.js tests/a2a/test_selling_console_frontend.py +PATH="$HOME/.local/bin:$PATH" git commit -m "feat: wire selling console interactions" +``` + +## Task 6: Documentation and Full Verification + +**Files:** +- Modify: `scripts/README.md` + +- [ ] **Step 1: Add failing README test or update existing script docs assertion** + +Add a small assertion to `tests/a2a/test_selling_console_script.py`: + +```python +def test_scripts_readme_mentions_selling_console() -> None: + readme = (Path(__file__).resolve().parents[2] / "scripts" / "README.md").read_text(encoding="utf-8") + + assert "a2a/selling_console.py" in readme + assert "Selling pipeline console" in readme +``` + +- [ ] **Step 2: Run test and verify RED** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py::test_scripts_readme_mentions_selling_console -v +``` + +Expected: FAIL until README is updated. + +- [ ] **Step 3: Update scripts README** + +Add a table row and command: + +```markdown +| `a2a/selling_console.py` | Selling pipeline console for local A2A interactions. | +``` + +Add usage: + +```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" +``` + +- [ ] **Step 4: Run focused tests** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py tests/a2a/test_selling_console_frontend.py -v +``` + +Expected: PASS. + +- [ ] **Step 5: Run relevant existing debugger tests** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_pipeline_debugger_script.py tests/a2a/test_selling_console_script.py tests/a2a/test_selling_console_frontend.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Start local console for browser verification** + +Run: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run python scripts/a2a/selling_console.py --port 41980 --default-cwd "$PWD" +``` + +Expected output includes: + +```text +A2A selling pipeline console: http://127.0.0.1:41980 +``` + +- [ ] **Step 7: Browser verification** + +Open `http://127.0.0.1:41980` in the in-app browser. + +Verify: + +- Desktop viewport shows topbar, left workflow panel, right plan title, and utility rail. +- Left panel resembles the screenshot: four main workflow cards, expanded plan selection card, bottom composer, disclaimer. +- Right side renders empty plan state without overlap. +- With no A2A server running, use `window.SellingConsoleDebug.loadDemoCandidates()` from the browser console; verify two candidate cards show in both left and right areas. +- Narrow viewport around 390px stacks the plan area below the workflow panel and no text overlaps. + +- [ ] **Step 8: Commit Task 6** + +Run: + +```bash +git add scripts/README.md tests/a2a/test_selling_console_script.py +PATH="$HOME/.local/bin:$PATH" git commit -m "docs: describe selling pipeline console" +``` + +## Final Verification + +- [ ] Run focused suite: + +```bash +PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_pipeline_debugger_script.py tests/a2a/test_selling_console_script.py tests/a2a/test_selling_console_frontend.py -v +``` + +- [ ] Run lint: + +```bash +PATH="$HOME/.local/bin:$PATH" make lint +``` + +- [ ] If i18n baseline is still missing `src/iac_code/i18n/messages.pot`, report that full `make test` remains blocked by the pre-existing missing POT file. Do not treat that as caused by this feature. + +## Self-Review + +- Spec coverage: + - New local script and static assets are covered by Tasks 1, 4, and 5. + - A2A proxy reuse is covered by Tasks 1 and 2. + - Full selling interaction is covered by Tasks 3 and 5. + - Screenshot visual match is covered by Task 4 and browser verification. + - Tests and docs are covered by Tasks 1 through 6. +- Placeholder scan: + - No placeholder markers, incomplete sections, or missing file paths are present. +- Type consistency: + - Python config is consistently named `SellingConsoleConfig`. + - Frontend state fields are consistently named `pipelineTaskId`, `activeTaskId`, `normalHandoffReady`, `pendingInput`, and `selectedCandidateIndex`. diff --git a/docs/superpowers/specs/2026-06-18-selling-pipeline-console-design.md b/docs/superpowers/specs/2026-06-18-selling-pipeline-console-design.md new file mode 100644 index 00000000..a4709d69 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-selling-pipeline-console-design.md @@ -0,0 +1,171 @@ +# Selling Pipeline Local Console Design + +## Purpose + +Build a local website under `scripts/` for the Alibaba Cloud selling pipeline. The site should present the full A2A pipeline interaction as a product console, matching the provided Alibaba Cloud console screenshots: top console navigation, a left AI workflow panel, a right "Your purchase plans" area, and a floating utility rail. + +The existing `scripts/a2a/debugger.py` remains the protocol reference. The new site reuses its A2A request, SSE, task identity, snapshot, pause, cancel, logging, and replay lessons, but changes the primary experience from a raw debugger to a selling pipeline workflow. + +## Goals + +- Add a new local entry point at `scripts/a2a/selling_console.py`. +- Add static web assets under `scripts/a2a/selling_console_web/`. +- Support the complete selling pipeline loop: + - Start from a deployment requirement prompt. + - Stream A2A pipeline events. + - Render requirement understanding, architecture planning, candidate evaluation, candidate selection, deployment, rollback, and handoff states. + - Handle `input_required` pauses for clarification, candidate choice, deployment confirmation, and permission-related waits. + - Continue normal chat after `pipeline_handoff_ready`. + - Cancel active tasks and fetch current pipeline state. +- Match the screenshots closely enough for product review: + - Alibaba Cloud-style top bar. + - Left workflow cards with green completion checks. + - Expanded "Plan selection" section with candidate radio cards. + - Bottom composer with "Deep thinking", attachment, and send controls. + - Right plan cards with orange monthly price, summary, and blue highlights. + - Desktop layout with a narrow left rail and right utility rail. +- Keep the tool local-only and unauthenticated, following the debugger model. + +## Non-Goals + +- Do not replace `scripts/a2a/debugger.py`. +- Do not introduce a frontend framework or package manager. +- Do not call real Alibaba Cloud APIs from the console itself. +- Do not add authentication, account switching, or real console navigation behavior. +- Do not rely on real LLM, real cloud credentials, or network calls in tests. + +## Architecture + +The new Python server follows the debugger's self-contained pattern: + +- `SellingConsoleConfig` + - host, port, default A2A server URL, default cwd, log directory, optional replay export. +- Protocol helpers + - Reuse or mirror the debugger semantics for: + - URL normalization. + - JSON fetch with A2A headers. + - `SendStreamingMessage`. + - `GetTask`. + - `CancelTask`. + - Pipeline state fetch. + - SSE line parsing. + - debug log append/load. +- HTTP routes + - `/` serves `index.html`. + - `/styles.css` and `/app.js` serve static assets safely from `selling_console_web`. + - `/api/health` proxies server health and agent card. + - `/api/message/stream` proxies A2A streaming responses. + - `/api/pipeline/state` proxies `iac-code/pipeline/state`. + - `/api/task/get` proxies `GetTask`. + - `/api/task/cancel` proxies `CancelTask`. + +The frontend owns the selling-specific rendering. It should not depend on hidden globals from `debugger.py`; instead, it receives defaults from a small JSON bootstrap script and uses browser-native JavaScript. + +## Frontend Layout + +The page uses three primary bands: + +- Top console bar + - Menu icon, Alibaba Cloud mark, workspace button, account resources dropdown, region dropdown, search box, docs/cost/ticket links, language and notification icons, user badge. + - These controls are visual affordances only. +- Main shell + - Left narrow assistant rail with the robot avatar. + - Left workflow panel, fixed around the screenshot proportions on desktop. + - Right content area titled "Your purchase plans". + - Right floating utility rail. +- Bottom composer inside the left panel + - Placeholder text for continuing or refining requirements. + - "Deep thinking" toggle-style button. + - Attachment icon and send icon button. + - Disclaimer text matching the screenshot tone. + +The layout must remain usable on narrow screens. Below desktop widths, the plan area stacks under the workflow panel and all text must fit without overlap. + +## Pipeline State Model + +The frontend reducer translates A2A events and snapshots into a UI model: + +- Session identity + - `contextId`, `pipelineTaskId`, `activeTaskId`, `lastSequence`, `status`, `normalHandoffReady`. +- Steps + - `intent_parsing` -> "Requirement understanding". + - `architecture_planning` -> "Architecture planning". + - `evaluate_candidates` and candidate sub-steps -> "Plan evaluation". + - `confirm_and_select` -> "Plan selection". + - `deploying` -> "Deployment". +- Candidate data + - Candidate name, zero-based index, summary, cost items, total monthly cost, diagram/artifact metadata, source raw event. + - Prefer `display.candidateDetails` and `candidate_detail` events. + - Fall back to `complete_step.conclusion.options` and candidate snapshot data. +- Pending input + - Question prompt, options, free-text allowance, related step/candidate coordinates. +- Permission state + - Tool name, safe summary, decision status, guidance text. +- Raw diagnostics + - Keep a collapsible debug drawer for requests, SSE events, and snapshots so protocol problems remain inspectable. + +The reducer must be tolerant of both camelCase and snake_case fields, because existing debugger code handles both. + +## Interaction Flow + +1. User enters a requirement in the composer and sends it. +2. The frontend posts `/api/message/stream` with `serverUrl`, `cwd`, current `contextId`, current stream task id, and prompt. +3. The stream parser reads SSE chunks incrementally, parses `data:` lines, appends diagnostics, and applies pipeline envelopes as they arrive. +4. If an `input_required` event or A2A input-required task status arrives, the stream reader cancels the browser reader and shows the pending question in the workflow panel. +5. When candidates are available, the console renders: + - radio cards in the left "Plan selection" section; + - larger plan cards in the right content area. +6. Choosing a candidate sets local selection state. Sending the selection emits a natural-language follow-up, such as `选择方案0` for candidate index 0. +7. Deployment confirmation and permission waits use the same pending-input path. The user response is sent as the next message in the same context. +8. When `pipeline_handoff_ready` switches to normal mode, clear the active pipeline task id and keep the context id so follow-up chat starts a new normal task. + +## Visual Details + +Colors and spacing should approximate the screenshots: + +- White page background with subtle blue/pink glow behind the left panel. +- Thin borders around workflow cards and plan cards. +- Alibaba Cloud orange for the logo and price. +- Bright green status checks. +- Blue highlight text for plan advantages. +- Rounded corners around cards, no nested decorative cards beyond the repeated workflow and plan cards. +- Compact but readable Chinese-first copy. + +Icons should be implemented as inline buttons using simple CSS or Unicode where no icon library exists. The implementation should avoid external CDNs. + +## Error Handling + +- Invalid server URL, missing cwd, and missing prompt show inline composer errors. +- Proxy failures show an alert row in the workflow panel and a diagnostic row in the debug drawer. +- Empty streams append a diagnostic event and leave the page interactive. +- Cancel failure displays an inline error without clearing the current state. +- Snapshot fetch failures do not destroy existing UI state. + +## Tests + +Add focused pytest coverage under `tests/a2a/`: + +- Script help exits successfully and describes the local selling console. +- Static index route serves HTML with default server URL and cwd. +- Static asset serving rejects path traversal. +- Defaults JSON is safe in script context. +- API payload builders preserve A2A v1 method names and cwd metadata. +- Existing debugger proxy behavior is not changed. +- Embedded or external JavaScript passes `node --check` when Node is installed. + +Manual/browser verification: + +- Start the local server. +- Open the page in the in-app browser. +- Verify desktop screenshot fit: top bar, workflow panel, plan cards, and utility rail are visible without overlap. +- Verify narrow viewport fit: content stacks and text remains readable. +- Exercise a mocked or replayed candidate-selection state if a real A2A server is unavailable. + +## Acceptance Criteria + +- `scripts/a2a/selling_console.py` can run locally with `uv run python scripts/a2a/selling_console.py`. +- The page visually matches the provided screenshots in structure, spacing, colors, and key copy. +- The console can start a selling pipeline request against a local A2A server. +- The console can render pipeline progress, candidate options, right-side plan cards, pending input, deployment status, and normal handoff. +- Tests for the new script and assets pass. +- Relevant existing A2A debugger tests still pass. diff --git a/scripts/README.md b/scripts/README.md index d83c1c79..3b5584c5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,6 +9,7 @@ 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 for local A2A interactions. | | `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. | @@ -23,6 +24,10 @@ repository root with `uv run python ...` unless a script-specific README says ot ```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 diff --git a/scripts/a2a/selling_console.py b/scripts/a2a/selling_console.py new file mode 100644 index 00000000..f2457bb4 --- /dev/null +++ b/scripts/a2a/selling_console.py @@ -0,0 +1,241 @@ +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") +STATIC_CONTENT_TYPES = { + "/styles.css": "text/css; charset=utf-8", + "/app.js": "application/javascript; charset=utf-8", +} +TEMPLATE_PLACEHOLDERS = ( + "__DEFAULTS_JSON__", + "__DEFAULT_SERVER_URL_ATTR__", + "__DEFAULT_CWD_ATTR__", + "__STATIC_ASSET_VERSION__", +) + + +@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_name in ("styles.css", "app.js"): + digest.update(asset_name.encode("utf-8")) + digest.update((WEB_ROOT / asset_name).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 _send_static(handler: BaseHTTPRequestHandler, path: str) -> bool: + if path not in STATIC_CONTENT_TYPES: + return False + candidate = (WEB_ROOT / path.lstrip("/")).resolve() + if not candidate.is_file() or WEB_ROOT.resolve() not in candidate.parents: + return False + _send_text(handler, 200, candidate.read_text(encoding="utf-8"), STATIC_CONTENT_TYPES[path]) + 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 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: + 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 ThreadingHTTPServer((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.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..39e72640 --- /dev/null +++ b/scripts/a2a/selling_console_web/README.md @@ -0,0 +1,32 @@ +# 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. + +## 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..7b7553c4 --- /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, "taskId", "task_id"); + } + + function contextIdOf(source) { + return valueOf(source, "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/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index f440c061..9f386ba7 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -554,7 +554,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)) @@ -563,7 +563,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 @@ -599,7 +599,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: diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py index 80ef6308..a5ca62c2 100644 --- a/tests/a2a/test_pipeline_events.py +++ b/tests/a2a/test_pipeline_events.py @@ -798,6 +798,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( diff --git a/tests/a2a/test_selling_console_frontend.py b/tests/a2a/test_selling_console_frontend.py new file mode 100644 index 00000000..107f4482 --- /dev/null +++ b/tests/a2a/test_selling_console_frontend.py @@ -0,0 +1,4861 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +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: + result = subprocess.run([*node_command(), "-e", source], capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +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
+ +
阿里云
+ + + + +
+
gaojiajia@test...RAM 用户
+
" 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..56d25737 --- /dev/null +++ b/tests/a2a/test_selling_console_script.py @@ -0,0 +1,992 @@ +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") + + +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 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_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 "", - ) - ) - - defaults_start = html.index("window.SELLING_CONSOLE_DEFAULTS = ") - script_end = html.index("", defaults_start) - defaults_assignment = html[defaults_start:script_end] - - assert " - - - -
阿里云
-

您的购买方案

- - -``` - -```css -.topbar { height: 56px; } -``` - -```javascript -(function () { - window.SellingConsoleReducers = {}; -})(); -``` - -Implement `create_server()` routes: - -```python -def create_server(config: SellingConsoleConfig) -> ThreadingHTTPServer: - class SellingConsoleHandler(BaseHTTPRequestHandler): - def log_message(self, format: str, *args: object) -> None: - return None - - def do_GET(self) -> None: - parsed = urlparse(self.path) - if parsed.path == "/": - _send_text(self, render_index_html(config), content_type="text/html") - return - if parsed.path == "/api/health": - status, body = a2a_debugger._health_response(a2a_debugger._query_params(self.path).get("serverUrl", "")) - _send_json(self, status, body) - return - _send_static(self, parsed.path) - return ThreadingHTTPServer((config.host, config.port), SellingConsoleHandler) -``` - -- [ ] **Step 4: Run tests and verify GREEN for server basics** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py -v -``` - -Expected: PASS for parser, help, index, escaping, static serving, and health route. - -- [ ] **Step 5: Commit Task 1** - -Run: - -```bash -git add scripts/a2a/selling_console.py scripts/a2a/selling_console_web/index.html scripts/a2a/selling_console_web/styles.css scripts/a2a/selling_console_web/app.js tests/a2a/test_selling_console_script.py -PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add selling pipeline console server" -``` - -## Task 2: Complete A2A Proxy Routes - -**Files:** -- Modify: `scripts/a2a/selling_console.py` -- Modify: `tests/a2a/test_selling_console_script.py` - -- [ ] **Step 1: Add failing proxy tests** - -Append tests for `/api/pipeline/state`, `/api/task/get`, `/api/task/cancel`, and `/api/message/stream`. Use the same `JsonTargetHandler` plus a new SSE target: - -```python -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","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) -``` - -Add assertions: - -```python -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 - - -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 - 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 - 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"}} -``` - -Add `post_raw()` helper to return status, text, and content type. - -- [ ] **Step 2: Run tests and verify RED** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py -v -``` - -Expected: FAIL on missing routes or incorrect proxy behavior. - -- [ ] **Step 3: Implement proxy routes** - -In `selling_console.py`, delegate to debugger helpers: - -```python -if parsed.path == "/api/pipeline/state": - status, body = a2a_debugger._pipeline_state_response(a2a_debugger._query_params(self.path)) - _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)) - _send_json(self, status, body) - return -``` - -For POST: - -```python -if parsed.path == "/api/message/stream": - body = _read_json_body(self) - server_url, payload = a2a_debugger._message_stream_body(body) - with a2a_debugger._open_sse_stream(server_url, payload) as response: - self.send_response(response.status) - self.send_header("Content-Type", "text/event-stream; charset=utf-8") - self.end_headers() - for line in response: - self.wfile.write(line) - self.wfile.flush() - return -if parsed.path == "/api/task/cancel": - body = _read_json_body(self) - status, response_body = a2a_debugger._task_cancel_response(body) - _send_json(self, status, response_body) - return -``` - -Wrap `ValueError` as `400` JSON and `HTTPError`, `URLError`, `TimeoutError`, `OSError` as proxy errors using debugger semantics. - -- [ ] **Step 4: Run tests and verify GREEN** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py -v -``` - -Expected: PASS. - -- [ ] **Step 5: Commit Task 2** - -Run: - -```bash -git add scripts/a2a/selling_console.py tests/a2a/test_selling_console_script.py -PATH="$HOME/.local/bin:$PATH" git commit -m "feat: proxy A2A routes for selling console" -``` - -## Task 3: Frontend Reducer Tests - -**Files:** -- Create: `tests/a2a/test_selling_console_frontend.py` -- Modify: `scripts/a2a/selling_console_web/app.js` - -- [ ] **Step 1: Write failing reducer tests** - -Create `tests/a2a/test_selling_console_frontend.py`: - -```python -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -import pytest - -APP_JS = Path(__file__).resolve().parents[2] / "scripts" / "a2a" / "selling_console_web" / "app.js" - - -def run_node_script(source: str) -> dict: - try: - result = subprocess.run(["node", "-e", source], capture_output=True, text=True, check=False) - except FileNotFoundError: - pytest.skip("node is not installed") - assert result.returncode == 0, result.stderr - return json.loads(result.stdout) - - -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 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"}); - 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: state.pipelineTaskId, - contextId: state.contextId, - sequence: state.lastSequence, - architectureStatus: state.steps.architecture_planning.status - }; - """ - ) - - assert output == { - "taskId": "task-1", - "contextId": "ctx-1", - "sequence": 3, - "architectureStatus": "completed", - } - - -def test_reducer_collects_candidate_details_from_tool_display() -> None: - output = reducer_harness( - """ - const state = reducers.createInitialState({}); - 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: state.candidates.length, - firstName: state.candidates[0].name, - firstCost: state.candidates[0].totalMonthlyCost, - pendingPrompt: state.pendingInput.prompt - }; - """ - ) - - assert output == { - "candidateCount": 1, - "firstName": "ECS 经典网络方案", - "firstCost": "¥33.89/月", - "pendingPrompt": "请选择方案", - } - - -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"; - 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: state.normalHandoffReady, - activeTaskId: state.activeTaskId, - contextId: state.contextId - }; - """ - ) - - assert output == { - "normalHandoffReady": True, - "activeTaskId": "", - "contextId": "ctx-1", - } -``` - -- [ ] **Step 2: Run tests and verify RED** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_frontend.py -v -``` - -Expected: FAIL because `createInitialState` and `reducePipelinePayload` are not implemented. - -- [ ] **Step 3: Implement reducer helpers** - -In `app.js`, expose pure helpers: - -```javascript -(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: "确认部署" - }; - - function createInitialState(defaults = {}) { - const steps = {}; - STEP_ORDER.forEach((id) => { - steps[id] = {id, label: STEP_LABELS[id], status: "pending", events: []}; - }); - return { - defaults, - serverUrl: defaults.serverUrl || "", - cwd: defaults.cwd || "", - contextId: "", - pipelineTaskId: "", - activeTaskId: "", - lastSequence: 0, - status: "idle", - normalHandoffReady: false, - steps, - candidates: [], - selectedCandidateIndex: null, - pendingInput: null, - permission: null, - diagnostics: {requests: [], sse: [], snapshots: []} - }; - } -``` - -Implement: - -- `extractPipelineEnvelope(payload)` tolerant of metadata and snapshot wrappers. -- `normalizeStepId(step)` mapping candidate sub-step events to `evaluate_candidates`. -- `upsertCandidate(state, candidate)`. -- `reducePipelinePayload(state, payload)`. -- `candidateFromDisplayItem(item)`. -- `pendingInputFromSnapshot(snapshot)`. - -- [ ] **Step 4: Run reducer tests and verify GREEN** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_frontend.py -v -``` - -Expected: PASS. - -- [ ] **Step 5: Commit Task 3** - -Run: - -```bash -git add scripts/a2a/selling_console_web/app.js tests/a2a/test_selling_console_frontend.py -PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add selling console frontend reducer" -``` - -## Task 4: Screenshot-Matching Static UI - -**Files:** -- Modify: `scripts/a2a/selling_console_web/index.html` -- Modify: `scripts/a2a/selling_console_web/styles.css` -- Modify: `scripts/a2a/selling_console_web/app.js` -- Modify: `tests/a2a/test_selling_console_script.py` - -- [ ] **Step 1: Add failing static UI contract test** - -Append: - -```python -def test_index_html_contains_screenshot_layout_regions() -> 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="/workspace/demo", - ) - ) - - for expected in [ - 'class="topbar"', - 'id="workflow-panel"', - 'id="plans-grid"', - 'id="composer-input"', - 'id="send-button"', - 'id="deep-think-button"', - 'id="debug-drawer"', - "需求理解", - "架构规划", - "方案评估", - "方案选择", - "您的购买方案", - "内容由 AI 生成,方案与价格仅供参考", - ]: - assert expected in html -``` - -Add CSS contract: - -```python -def test_styles_define_console_layout_tokens() -> None: - css = (SCRIPT_PATH.parent / "selling_console_web" / "styles.css").read_text(encoding="utf-8") - - for expected in [ - "--aliyun-orange", - ".console-shell", - ".workflow-panel", - ".plan-card", - ".price", - ".utility-rail", - "@media (max-width: 980px)", - ]: - assert expected in css -``` - -Add JS syntax check: - -```python -def test_frontend_javascript_is_syntax_valid() -> None: - app_js = SCRIPT_PATH.parent / "selling_console_web" / "app.js" - try: - result = subprocess.run(["node", "--check", str(app_js)], capture_output=True, text=True, check=False) - except FileNotFoundError: - pytest.skip("node is not installed") - - assert result.returncode == 0, result.stderr -``` - -- [ ] **Step 2: Run static UI tests and verify RED** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py::test_index_html_contains_screenshot_layout_regions tests/a2a/test_selling_console_script.py::test_styles_define_console_layout_tokens tests/a2a/test_selling_console_script.py::test_frontend_javascript_is_syntax_valid -v -``` - -Expected: FAIL on missing layout regions and CSS tokens. - -- [ ] **Step 3: Implement HTML shell** - -Replace the stub HTML with: - -```html - -
- -
阿里云
- - - - - -
gaojiajia@test...RAM 用户
-
-
- -
- -
-
- -
- - - - -
-
-

内容由 AI 生成,方案与价格仅供参考,请以实际部署结果为准。

-
-
-
-

您的购买方案

-
- - - - - -
-
-
-
- 调试信息 -
-
-
- -
- -``` - -- [ ] **Step 4: Implement screenshot CSS** - -Set desktop dimensions close to the screenshot: - -```css -:root { - --aliyun-orange: #ff6a00; - --accent-blue: #4f7cff; - --success-green: #46c22f; - --text-strong: #1f2329; - --text-muted: #858b99; - --line: #d9e2ef; - --panel: #ffffff; - --soft-bg: #f7faff; -} - -body { - margin: 0; - color: var(--text-strong); - background: #fff; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -.topbar { - display: grid; - grid-template-columns: 56px 132px auto auto auto minmax(280px, 1fr) auto auto; - align-items: center; - gap: 10px; - height: 64px; - padding: 0 18px; - border-bottom: 1px solid #edf1f7; - box-shadow: 0 2px 14px rgba(31, 35, 41, 0.08); -} - -.console-shell { - display: grid; - grid-template-columns: 48px minmax(460px, 640px) minmax(520px, 1fr) 64px; - min-height: calc(100vh - 64px); -} - -.workflow-panel { - position: relative; - padding: 42px 22px 20px 16px; - border-right: 1px solid #e7edf5; - background: - radial-gradient(circle at 10% 100%, rgba(237, 244, 255, 0.95), transparent 38%), - radial-gradient(circle at 90% 100%, rgba(255, 238, 246, 0.75), transparent 34%), - #fff; -} - -.plan-card { - min-height: 260px; - border: 1px solid #dde5f0; - border-radius: 8px; - padding: 28px; - background: #fff; -} - -.price { - color: var(--aliyun-orange); - font-size: 34px; - font-weight: 800; -} - -@media (max-width: 980px) { - .topbar { grid-template-columns: 48px 120px 1fr; } - .topbar .nav-pill, - .topbar .top-links, - .topbar .user-menu { display: none; } - .console-shell { grid-template-columns: 1fr; } - .assistant-rail, - .utility-rail { display: none; } - .workflow-panel { border-right: 0; } - .plans-grid { grid-template-columns: 1fr; } -} -``` - -- [ ] **Step 5: Run static UI tests and verify GREEN** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py::test_index_html_contains_screenshot_layout_regions tests/a2a/test_selling_console_script.py::test_styles_define_console_layout_tokens tests/a2a/test_selling_console_script.py::test_frontend_javascript_is_syntax_valid -v -``` - -Expected: PASS. - -- [ ] **Step 6: Commit Task 4** - -Run: - -```bash -git add scripts/a2a/selling_console_web/index.html scripts/a2a/selling_console_web/styles.css scripts/a2a/selling_console_web/app.js tests/a2a/test_selling_console_script.py -PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add selling console visual shell" -``` - -## Task 5: DOM Controller and Complete Interaction - -**Files:** -- Modify: `scripts/a2a/selling_console_web/app.js` -- Modify: `tests/a2a/test_selling_console_frontend.py` - -- [ ] **Step 1: Add failing interaction reducer tests** - -Append frontend tests for composer payload and candidate selection: - -```python -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"; - return reducers.buildStreamPayload(state, "继续部署"); - """ - ) - - assert output == { - "serverUrl": "http://server", - "cwd": "/workspace", - "contextId": "ctx-1", - "taskId": "active-task", - "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} - ]; - reducers.selectCandidate(state, 1); - return { - selected: state.selectedCandidateIndex, - prompt: reducers.promptForSelectedCandidate(state) - }; - """ - ) - - assert output == {"selected": 1, "prompt": "选择方案1"} -``` - -- [ ] **Step 2: Run tests and verify RED** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_frontend.py -v -``` - -Expected: FAIL on missing `buildStreamPayload`, `selectCandidate`, or `promptForSelectedCandidate`. - -- [ ] **Step 3: Implement DOM controller and remaining pure helpers** - -Add: - -```javascript -function buildStreamPayload(state, prompt) { - const taskId = state.normalHandoffReady ? "" : state.activeTaskId || state.pipelineTaskId || ""; - return { - serverUrl: state.serverUrl, - cwd: state.cwd, - contextId: state.contextId, - taskId, - prompt - }; -} - -function selectCandidate(state, candidateIndex) { - state.selectedCandidateIndex = Number(candidateIndex); -} - -function promptForSelectedCandidate(state) { - if (state.selectedCandidateIndex === null || state.selectedCandidateIndex === undefined) { - return ""; - } - return `选择方案${state.selectedCandidateIndex}`; -} -``` - -Add DOM functions: - -- `init()` reads `window.SELLING_CONSOLE_DEFAULTS`, creates state, binds buttons, renders empty state. -- `renderSteps()` creates workflow cards and candidate radio cards. -- `renderPlans()` creates right-side plan cards. -- `sendComposerMessage()` validates input, builds payload, streams SSE, and stops on `input_required`. -- `healthCheck()`, `fetchState()`, `cancelTask()`. -- `appendDiagnostic(kind, value)`, `renderDebug()`. -- `window.SellingConsoleDebug.loadDemoCandidates()` for browser verification without a live A2A server. It should inject two candidates matching the screenshot copy and re-render the workflow and plan cards. - -Use only `textContent`, `createElement`, and `setAttribute` for dynamic content. Avoid assigning user-controlled values to `innerHTML`. - -- [ ] **Step 4: Run frontend tests and syntax check** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_frontend.py tests/a2a/test_selling_console_script.py::test_frontend_javascript_is_syntax_valid -v -``` - -Expected: PASS. - -- [ ] **Step 5: Commit Task 5** - -Run: - -```bash -git add scripts/a2a/selling_console_web/app.js tests/a2a/test_selling_console_frontend.py -PATH="$HOME/.local/bin:$PATH" git commit -m "feat: wire selling console interactions" -``` - -## Task 6: Documentation and Full Verification - -**Files:** -- Modify: `scripts/README.md` - -- [ ] **Step 1: Add failing README test or update existing script docs assertion** - -Add a small assertion to `tests/a2a/test_selling_console_script.py`: - -```python -def test_scripts_readme_mentions_selling_console() -> None: - readme = (Path(__file__).resolve().parents[2] / "scripts" / "README.md").read_text(encoding="utf-8") - - assert "a2a/selling_console.py" in readme - assert "Selling pipeline console" in readme -``` - -- [ ] **Step 2: Run test and verify RED** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py::test_scripts_readme_mentions_selling_console -v -``` - -Expected: FAIL until README is updated. - -- [ ] **Step 3: Update scripts README** - -Add a table row and command: - -```markdown -| `a2a/selling_console.py` | Selling pipeline console for local A2A interactions. | -``` - -Add usage: - -```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" -``` - -- [ ] **Step 4: Run focused tests** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_selling_console_script.py tests/a2a/test_selling_console_frontend.py -v -``` - -Expected: PASS. - -- [ ] **Step 5: Run relevant existing debugger tests** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_pipeline_debugger_script.py tests/a2a/test_selling_console_script.py tests/a2a/test_selling_console_frontend.py -v -``` - -Expected: PASS. - -- [ ] **Step 6: Start local console for browser verification** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run python scripts/a2a/selling_console.py --port 41980 --default-cwd "$PWD" -``` - -Expected output includes: - -```text -A2A selling pipeline console: http://127.0.0.1:41980 -``` - -- [ ] **Step 7: Browser verification** - -Open `http://127.0.0.1:41980` in the in-app browser. - -Verify: - -- Desktop viewport shows topbar, left workflow panel, right plan title, and utility rail. -- Left panel resembles the screenshot: four main workflow cards, expanded plan selection card, bottom composer, disclaimer. -- Right side renders empty plan state without overlap. -- With no A2A server running, use `window.SellingConsoleDebug.loadDemoCandidates()` from the browser console; verify two candidate cards show in both left and right areas. -- Narrow viewport around 390px stacks the plan area below the workflow panel and no text overlaps. - -- [ ] **Step 8: Commit Task 6** - -Run: - -```bash -git add scripts/README.md tests/a2a/test_selling_console_script.py -PATH="$HOME/.local/bin:$PATH" git commit -m "docs: describe selling pipeline console" -``` - -## Final Verification - -- [ ] Run focused suite: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/a2a/test_pipeline_debugger_script.py tests/a2a/test_selling_console_script.py tests/a2a/test_selling_console_frontend.py -v -``` - -- [ ] Run lint: - -```bash -PATH="$HOME/.local/bin:$PATH" make lint -``` - -- [ ] If i18n baseline is still missing `src/iac_code/i18n/messages.pot`, report that full `make test` remains blocked by the pre-existing missing POT file. Do not treat that as caused by this feature. - -## Self-Review - -- Spec coverage: - - New local script and static assets are covered by Tasks 1, 4, and 5. - - A2A proxy reuse is covered by Tasks 1 and 2. - - Full selling interaction is covered by Tasks 3 and 5. - - Screenshot visual match is covered by Task 4 and browser verification. - - Tests and docs are covered by Tasks 1 through 6. -- Placeholder scan: - - No placeholder markers, incomplete sections, or missing file paths are present. -- Type consistency: - - Python config is consistently named `SellingConsoleConfig`. - - Frontend state fields are consistently named `pipelineTaskId`, `activeTaskId`, `normalHandoffReady`, `pendingInput`, and `selectedCandidateIndex`. diff --git a/docs/superpowers/plans/2026-06-19-repl-pipeline-e2e.md b/docs/superpowers/plans/2026-06-19-repl-pipeline-e2e.md deleted file mode 100644 index dc3d82a2..00000000 --- a/docs/superpowers/plans/2026-06-19-repl-pipeline-e2e.md +++ /dev/null @@ -1,1049 +0,0 @@ -# REPL Pipeline E2E Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a real PTY-driven `scripts/repl/e2e/run_pipeline_scenarios.py` runner that regresses selling pipeline behavior through the interactive REPL. - -**Architecture:** The runner is a script, not a pytest suite. It uses `pexpect` to spawn `iac-code` in pipeline mode, writes redacted transcripts and JSON summaries under `/tmp`, and exposes scenario handlers for baseline, ask, resume, and interrupt flows. Pytest coverage only targets pure script helpers and scenario dispatch logic, never real LLM or cloud calls. - -**Tech Stack:** Python 3.10+, `pexpect`, `argparse`, `json`, `tempfile`, `pathlib`, `pytest`, existing `uv` dependency management. - ---- - -## File Structure - -- Modify `pyproject.toml`: add `pexpect` to the `dev` dependency group. -- Modify `uv.lock`: refresh lockfile after adding `pexpect`. -- Create `scripts/repl/e2e/run_pipeline_scenarios.py`: CLI, PTY harness, redaction, transcript normalization, run artifact writing, scenario handlers. -- Create `scripts/repl/e2e/README.zh-CN.md`: usage, safety warning, scenario descriptions, artifact inspection. -- Modify `scripts/README.md`: list the new REPL pipeline e2e runner. -- Create `tests/repl_e2e/test_run_pipeline_scenarios.py`: helper and dispatch tests that do not spawn the real REPL. - -## Task 1: Add Dependency And Helper Test Skeleton - -**Files:** -- Modify: `pyproject.toml` -- Modify: `uv.lock` -- Create: `tests/repl_e2e/test_run_pipeline_scenarios.py` - -- [ ] **Step 1: Write the failing import/helper tests** - -Create `tests/repl_e2e/test_run_pipeline_scenarios.py` with this initial content: - -```python -from __future__ import annotations - -import importlib.util -import os -import sys -from pathlib import Path - - -def _load_runner(): - path = Path(__file__).resolve().parents[2] / "scripts" / "repl" / "e2e" / "run_pipeline_scenarios.py" - spec = importlib.util.spec_from_file_location("run_pipeline_scenarios", path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def test_parse_args_defaults_to_scenario1() -> None: - runner = _load_runner() - - args = runner.parse_args([]) - - assert args.scenario is None - assert runner._selected_scenarios(args) == ["scenario1"] - assert args.python == "uv run python" - - -def test_validate_requires_real_cloud_flag() -> None: - runner = _load_runner() - args = runner.parse_args(["--scenario", "scenario1"]) - - try: - runner._validate_scenario_execution(args, "scenario1") - except SystemExit as exc: - assert "--allow-real-cloud" in str(exc) - else: - raise AssertionError("scenario1 should require --allow-real-cloud") - - -def test_redaction_hides_sensitive_env_values() -> None: - runner = _load_runner() - - text = "Authorization: Bearer sk-live-secret and token abcdefghijklmnop" - env = { - "IAC_CODE_API_KEY": "sk-live-secret", - "CUSTOM_TOKEN": "abcdefghijklmnop", - "IAC_CODE_MODEL": "qwen3.6-plus", - } - - redacted = runner._redact_sensitive_text(text, env) - - assert "sk-live-secret" not in redacted - assert "abcdefghijklmnop" not in redacted - assert "qwen3.6-plus" not in redacted - assert "" in redacted - - -def test_normalize_transcript_strips_ansi_and_control_noise() -> None: - runner = _load_runner() - - normalized = runner._normalize_transcript("\x1b[31mPipeline\x1b[0m\r\n❯ hello\x08\x08ok") - - assert "\x1b" not in normalized - assert "Pipeline" in normalized - assert "ok" in normalized - - -def test_build_child_env_sets_pipeline_mode_without_overriding_home(monkeypatch) -> None: - runner = _load_runner() - monkeypatch.setenv("HOME", "/Users/example") - monkeypatch.setenv("IAC_CODE_CONFIG_DIR", "/custom/iac") - args = runner.parse_args(["--allow-real-cloud", "--provider", "dashscope", "--model", "qwen3.6-plus"]) - - env = runner._build_child_env(args) - - assert env["HOME"] == "/Users/example" - assert env["IAC_CODE_CONFIG_DIR"] == "/custom/iac" - assert env["IAC_CODE_MODE"] == "pipeline" - assert env["IAC_CODE_PROVIDER"] == "dashscope" - assert env["IAC_CODE_MODEL"] == "qwen3.6-plus" - assert env["PYTHONUTF8"] == "1" -``` - -- [ ] **Step 2: Run the failing tests** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py -v -``` - -Expected: FAIL because `scripts/repl/e2e/run_pipeline_scenarios.py` does not exist. - -- [ ] **Step 3: Add `pexpect` to project dependencies** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv add --dev pexpect -``` - -Expected: `pyproject.toml` gains `pexpect>=...` in `[dependency-groups].dev`, and `uv.lock` gains `pexpect` and `ptyprocess`. - -- [ ] **Step 4: Verify dependency import** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run python -c "import pexpect; print(pexpect.__version__)" -``` - -Expected: prints a pexpect version. - -- [ ] **Step 5: Commit dependency and failing helper tests** - -Run: - -```bash -git add pyproject.toml uv.lock tests/repl_e2e/test_run_pipeline_scenarios.py -PATH="$HOME/.local/bin:$PATH" git commit -m "test: add REPL pipeline e2e helper tests" -``` - -Expected: commit succeeds after hooks pass or only expected failing tests remain uncommitted if hooks run the full suite. If hooks require passing tests, postpone this commit until Task 2. - -## Task 2: Implement Runner Core Helpers - -**Files:** -- Create: `scripts/repl/e2e/run_pipeline_scenarios.py` -- Modify: `tests/repl_e2e/test_run_pipeline_scenarios.py` - -- [ ] **Step 1: Create the runner module with CLI and pure helpers** - -Create `scripts/repl/e2e/run_pipeline_scenarios.py` with: - -```python -#!/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 json -import os -import re -import shlex -import signal -import sys -import tempfile -import time -import uuid -from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Callable - -try: - import pexpect -except ImportError: # pragma: no cover - exercised manually when dependency missing - pexpect = None # type: ignore[assignment] - - -RUN_LOG_ROOT_NAME = "iac-code-repl-e2e-runs" -DEFAULT_INITIAL_PROMPT = "选择一个已有vpc,创建一个vswitch" -DEFAULT_SELECTION_PROMPT = "" -DEFAULT_ASK_PROMPT = "我有个产品要上线" -DEFAULT_ASK_ANSWER = "我要创建云网络资源;本次只选择已有 VPC 创建一个 VSwitch,不部署 ECS、EIP、SLB 或 Nginx。" -DEFAULT_NORMAL_FOLLOWUP_PROMPT = "你刚才创建了什么" -DEFAULT_ROLLBACK_PROMPT = "回退到 intent_parsing,选择一个已有vpc,创建一个安全组" - -PIPELINE_STARTED_PATTERNS = (r"Pipeline", r"pipeline", r"intent_parsing", r"意图") -CANDIDATE_SELECTION_PATTERNS = (r"confirm_and_select", r"候选", r"方案", r"选择") -ASK_PATTERNS = (r"请.*补充", r"需要.*信息", r"澄清", r"问题") -PIPELINE_COMPLETED_PATTERNS = (r"Pipeline completed", r"pipeline completed", r"完成", r"handoff", r"交接") -ROLLBACK_PATTERNS = (r"rollback", r"回退", r"重启", r"intent_parsing") - - -@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) - - -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("--leave-running", action="store_true") - parser.add_argument("--initial-prompt", default=DEFAULT_INITIAL_PROMPT) - parser.add_argument("--selection-prompt", default=DEFAULT_SELECTION_PROMPT) - 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) - 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) - 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"sk-[A-Za-z0-9_-]{8,}", "sk-", 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 _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) - - -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_scenario1(args: argparse.Namespace, scenario: str) -> int: - raise NotImplementedError - - -def run_ask_waiting(args: argparse.Namespace, scenario: str) -> int: - raise NotImplementedError - - -def run_selection_waiting_resume(args: argparse.Namespace, scenario: str) -> int: - raise NotImplementedError - - -def run_rollback_step3(args: argparse.Namespace, scenario: str) -> int: - raise NotImplementedError - - -_SCENARIOS: dict[str, Callable[[argparse.Namespace, str], int]] = { - "scenario1": run_scenario1, - "ask-waiting": run_ask_waiting, - "selection-waiting-resume": run_selection_waiting_resume, - "rollback-step3": run_rollback_step3, -} -_REAL_CLOUD_SCENARIOS = frozenset(_SCENARIOS) - - -if __name__ == "__main__": - raise SystemExit(main()) -``` - -- [ ] **Step 2: Run helper tests** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py -v -``` - -Expected: PASS for helper tests. - -- [ ] **Step 3: Add parser test for repeated scenarios and run-dir validation** - -Append to `tests/repl_e2e/test_run_pipeline_scenarios.py`: - -```python -def test_repeated_scenarios_are_preserved() -> None: - runner = _load_runner() - args = runner.parse_args(["--scenario", "scenario1", "--scenario", "ask-waiting", "--allow-real-cloud"]) - - assert runner._selected_scenarios(args) == ["scenario1", "ask-waiting"] - - -def test_run_dir_requires_single_scenario() -> None: - runner = _load_runner() - - try: - runner.main([ - "--scenario", - "scenario1", - "--scenario", - "ask-waiting", - "--allow-real-cloud", - "--run-dir", - "/tmp/repl-e2e", - ]) - except SystemExit as exc: - assert "--run-dir can only be used with a single --scenario" in str(exc) - else: - raise AssertionError("--run-dir should reject multiple scenarios") -``` - -- [ ] **Step 4: Run helper tests again** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py -v -``` - -Expected: PASS. - -- [ ] **Step 5: Commit core helper implementation** - -Run: - -```bash -git add scripts/repl/e2e/run_pipeline_scenarios.py tests/repl_e2e/test_run_pipeline_scenarios.py -PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add REPL pipeline e2e runner core" -``` - -Expected: commit succeeds. - -## Task 3: Implement PTY Harness And Artifact Writing - -**Files:** -- Modify: `scripts/repl/e2e/run_pipeline_scenarios.py` -- Modify: `tests/repl_e2e/test_run_pipeline_scenarios.py` - -- [ ] **Step 1: Add unit tests for artifact result serialization** - -Append to `tests/repl_e2e/test_run_pipeline_scenarios.py`: - -```python -def test_write_result_writes_summary_and_transcripts(tmp_path: Path) -> None: - runner = _load_runner() - result = runner.ScenarioRunResult( - scenario="scenario1", - run_dir=str(tmp_path), - passed=True, - checks={"pipeline started": True}, - elapsed_seconds=1.25, - ) - - runner._write_run_artifacts( - run_dir=tmp_path, - env={"IAC_CODE_API_KEY": "sk-secret123456", "IAC_CODE_MODEL": "qwen3.6-plus"}, - raw_transcript="hello sk-secret123456", - events=[{"type": "check", "name": "pipeline started", "passed": True}], - result=result, - ) - - summary = (tmp_path / "summary.json").read_text(encoding="utf-8") - raw = (tmp_path / "transcript.raw.log").read_text(encoding="utf-8") - normalized = (tmp_path / "transcript.normalized.log").read_text(encoding="utf-8") - events = (tmp_path / "events.jsonl").read_text(encoding="utf-8") - - assert "sk-secret123456" not in summary - assert "sk-secret123456" not in raw - assert "sk-secret123456" not in normalized - assert "pipeline started" in events -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py::test_write_result_writes_summary_and_transcripts -v -``` - -Expected: FAIL because `_write_run_artifacts` is not implemented. - -- [ ] **Step 3: Implement `_write_run_artifacts` and `ReplPty`** - -Add to `scripts/repl/e2e/run_pipeline_scenarios.py` above the scenario functions: - -```python -class ReplPty: - def __init__(self, *, args: argparse.Namespace, run_dir: Path, cwd: Path, env: dict[str, str]) -> None: - 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 - - @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), - ) - - def sendline(self, text: str) -> None: - self._require_child().sendline(text) - self.events.append({"type": "sendline", "text": _redact_sensitive_text(text, self.env), "at": _utc_now()}) - - def send(self, text: str, *, label: str = "send") -> None: - self._require_child().send(text) - self.events.append({"type": label, "text": _redact_sensitive_text(text, self.env), "at": _utc_now()}) - - def expect_any(self, patterns: tuple[str, ...], *, description: str, timeout: float) -> str: - child = self._require_child() - try: - index = child.expect(list(patterns), timeout=timeout) - self._capture_child_output(child.before + child.after) - matched = patterns[index] - self.events.append( - {"type": "expect", "description": description, "pattern": matched, "passed": True, "at": _utc_now()} - ) - return matched - except Exception as exc: - self._capture_child_output(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 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(getattr(child, "before", "") or "") - self.events.append({"type": "terminate", "force": force, "at": _utc_now()}) - - def _capture_child_output(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 - - -def _utc_now() -> str: - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - - -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 _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 -``` - -- [ ] **Step 4: Run targeted artifact test** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py::test_write_result_writes_summary_and_transcripts -v -``` - -Expected: PASS. - -- [ ] **Step 5: Run all runner helper tests** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py -v -``` - -Expected: PASS. - -- [ ] **Step 6: Commit PTY harness** - -Run: - -```bash -git add scripts/repl/e2e/run_pipeline_scenarios.py tests/repl_e2e/test_run_pipeline_scenarios.py -PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add REPL PTY e2e harness" -``` - -Expected: commit succeeds. - -## Task 4: Implement Pipeline Scenario Handlers - -**Files:** -- Modify: `scripts/repl/e2e/run_pipeline_scenarios.py` -- Modify: `tests/repl_e2e/test_run_pipeline_scenarios.py` - -- [ ] **Step 1: Add dispatch tests with fake harness** - -Append: - -```python -def test_scenario1_runs_expected_terminal_flow(monkeypatch, tmp_path: Path) -> None: - runner = _load_runner() - actions: list[tuple[str, str]] = [] - - class FakePty: - def __init__(self, *, args, run_dir, cwd, env): - self.events = [] - self.transcript = "Pipeline 候选 完成 normal answer" - - def spawn(self, *, extra_args=None): - actions.append(("spawn", "")) - - def sendline(self, text): - actions.append(("sendline", text)) - - def expect_any(self, patterns, *, description, timeout): - actions.append(("expect", description)) - return patterns[0] - - def send(self, text, *, label="send"): - actions.append((label, text)) - - def terminate(self, *, force=False): - actions.append(("terminate", str(force))) - - monkeypatch.setattr(runner, "ReplPty", FakePty) - args = runner.parse_args(["--allow-real-cloud", "--run-dir", str(tmp_path)]) - - assert runner.run_scenario1(args, "scenario1") == 0 - assert ("sendline", runner.DEFAULT_INITIAL_PROMPT) in actions - assert ("sendline", runner.DEFAULT_NORMAL_FOLLOWUP_PROMPT) in actions - assert ("sendline", "/exit") in actions -``` - -- [ ] **Step 2: Run dispatch test to verify failure** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py::test_scenario1_runs_expected_terminal_flow -v -``` - -Expected: FAIL because scenario handlers still raise `NotImplementedError`. - -- [ ] **Step 3: Implement harness orchestration and scenario handlers** - -Replace the `NotImplementedError` scenario functions in `run_pipeline_scenarios.py` with: - -```python -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 - try: - pty.spawn() - callback(pty, checks) - passed = all(checks.values()) if checks else True - except Exception as exc: - abort_reason = f"{type(exc).__name__}: {exc}" - notes.append(abort_reason) - passed = False - finally: - if not args.leave_running: - pty.terminate() - 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"\nscenario: {result.scenario}") - print(f"run_dir: {result.run_dir}") - 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 _select_default_candidate(pty: ReplPty, args: argparse.Namespace) -> None: - if args.selection_prompt: - pty.sendline(args.selection_prompt) - else: - pty.send("\r", label="select-default-candidate") - - -def run_scenario1(args: argparse.Namespace, scenario: str) -> int: - def callback(pty: ReplPty, checks: dict[str, bool]) -> None: - pty.expect_any((r"❯", r">", r"pipeline"), description="initial prompt", timeout=args.timeout) - pty.sendline(args.initial_prompt) - pty.expect_any(PIPELINE_STARTED_PATTERNS, description="pipeline started", timeout=args.stream_timeout) - checks["pipeline started"] = True - pty.expect_any(CANDIDATE_SELECTION_PATTERNS, description="candidate selection visible", timeout=args.stream_timeout) - 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", timeout=args.stream_timeout) - checks["pipeline completed"] = True - pty.sendline(args.normal_followup_prompt) - pty.expect_any((r".{20,}",), description="normal follow-up produced output", timeout=args.stream_timeout) - checks["normal follow-up produced text"] = 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: - pty.expect_any((r"❯", r">", r"pipeline"), description="initial prompt", timeout=args.timeout) - 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(args.ask_answer) - checks["ask answer sent"] = True - pty.expect_any(CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS, description="pipeline continued after ask", timeout=args.stream_timeout) - checks["pipeline continued beyond ask"] = True - 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: - pty.expect_any((r"❯", r">", r"pipeline"), description="initial prompt", timeout=args.timeout) - pty.sendline(args.initial_prompt) - pty.expect_any(CANDIDATE_SELECTION_PATTERNS, description="candidate selection visible", timeout=args.stream_timeout) - checks["candidate selection became visible before kill"] = True - pty.terminate(force=True) - checks["first process killed"] = True - pty.spawn(extra_args=["--continue"]) - pty.expect_any(CANDIDATE_SELECTION_PATTERNS, description="candidate selection replayed", timeout=args.stream_timeout) - checks["candidate selection replayed"] = True - _select_default_candidate(pty, args) - 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_rollback_step3(args: argparse.Namespace, scenario: str) -> int: - def callback(pty: ReplPty, checks: dict[str, bool]) -> None: - pty.expect_any((r"❯", r">", r"pipeline"), description="initial prompt", timeout=args.timeout) - pty.sendline(args.initial_prompt) - pty.expect_any((r"evaluate_candidates", r"候选", r"方案"), description="candidate evaluation visible", timeout=args.stream_timeout) - checks["candidate evaluation reached"] = True - pty.send("\x1b", label="send-esc") - checks["esc sent"] = True - pty.expect_any((r"✎", r"interrupt", r"输入", r"Judging"), description="interrupt input visible", timeout=args.timeout) - pty.sendline(args.rollback_prompt) - checks["rollback prompt sent"] = True - pty.expect_any(ROLLBACK_PATTERNS, description="rollback progress visible", timeout=args.stream_timeout) - checks["rollback progress visible"] = True - pty.sendline("/exit") - return _run_with_pty(args, scenario, callback) -``` - -- [ ] **Step 4: Run scenario dispatch tests** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py -v -``` - -Expected: PASS. - -- [ ] **Step 5: Run script help without real cloud** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run python scripts/repl/e2e/run_pipeline_scenarios.py --help -``` - -Expected: prints CLI help and exits 0. - -- [ ] **Step 6: Commit scenario implementation** - -Run: - -```bash -git add scripts/repl/e2e/run_pipeline_scenarios.py tests/repl_e2e/test_run_pipeline_scenarios.py -PATH="$HOME/.local/bin:$PATH" git commit -m "feat: add REPL pipeline e2e scenarios" -``` - -Expected: commit succeeds. - -## Task 5: Add Documentation - -**Files:** -- Create: `scripts/repl/e2e/README.zh-CN.md` -- Modify: `scripts/README.md` - -- [ ] **Step 1: Add README** - -Create `scripts/repl/e2e/README.zh-CN.md`: - -```markdown -# 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 中执行真实场景。 - -## 快速开始 - -```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 | -| `selection-waiting-resume` | candidate selection 等待时杀进程,重启后恢复选择 UI 并继续 | -| `rollback-step3` | pipeline 中发送 Esc 和回退指令,验证 REPL hard interrupt 路径 | - -## 产物 - -默认写入: - -```text -/tmp/iac-code-repl-e2e-runs//--/ -``` - -关键文件: - -- `summary.json` -- `events.jsonl` -- `transcript.raw.log` -- `transcript.normalized.log` -- `child.env.json` - -失败时优先看 `summary.json` 和 `events.jsonl`,再看 normalized transcript。 -``` - -- [ ] **Step 2: Update scripts README** - -Modify `scripts/README.md` layout table to add: - -```markdown -| `repl/e2e/` | Interactive REPL pipeline end-to-end scenario runner. | -``` - -Add command: - -```markdown -uv run python scripts/repl/e2e/run_pipeline_scenarios.py --help -``` - -- [ ] **Step 3: Run docs-adjacent smoke commands** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run python scripts/repl/e2e/run_pipeline_scenarios.py --help -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py -v -``` - -Expected: help exits 0 and tests pass. - -- [ ] **Step 4: Commit docs** - -Run: - -```bash -git add scripts/repl/e2e/README.zh-CN.md scripts/README.md -PATH="$HOME/.local/bin:$PATH" git commit -m "docs: document REPL pipeline e2e runner" -``` - -Expected: commit succeeds. - -## Task 6: Final Verification - -**Files:** -- No new files unless verification exposes needed fixes. - -- [ ] **Step 1: Run targeted tests** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run pytest tests/repl_e2e/test_run_pipeline_scenarios.py -v -``` - -Expected: PASS. - -- [ ] **Step 2: Run script help** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run python scripts/repl/e2e/run_pipeline_scenarios.py --help -``` - -Expected: exits 0 and lists all scenarios. - -- [ ] **Step 3: Run lint for touched areas** - -Run: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run ruff check scripts/repl/e2e/run_pipeline_scenarios.py tests/repl_e2e/test_run_pipeline_scenarios.py -PATH="$HOME/.local/bin:$PATH" uv run ruff format --check scripts/repl/e2e/run_pipeline_scenarios.py tests/repl_e2e/test_run_pipeline_scenarios.py -``` - -Expected: PASS. - -- [ ] **Step 4: Do not run real scenario automatically** - -Do not run this during automated verification: - -```bash -PATH="$HOME/.local/bin:$PATH" uv run python scripts/repl/e2e/run_pipeline_scenarios.py --allow-real-cloud --scenario scenario1 -``` - -Reason: it intentionally uses real `~/.iac-code`, real LLM, and potentially real cloud resources. Leave it as the manual regression command in the final response. - -- [ ] **Step 5: Final status** - -Run: - -```bash -git status --short -``` - -Expected: clean working tree. diff --git a/docs/superpowers/plans/2026-06-23-review-fixes.md b/docs/superpowers/plans/2026-06-23-review-fixes.md deleted file mode 100644 index bcb789da..00000000 --- a/docs/superpowers/plans/2026-06-23-review-fixes.md +++ /dev/null @@ -1,1671 +0,0 @@ -# Review Fixes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix every actionable item in `/Users/ehzyo/open_repo/iac-code3/docs/review.md`, including the historical hardening section, then review and repair until no review findings remain. - -**Architecture:** Add a small shared state I/O foundation, then route recovery-relevant A2A and pipeline state changes through durable persistence boundaries. Keep cleanup service truth in the private ledger, keep the public A2A snapshot display-only, and close Windows, i18n, documentation, and minor compatibility gaps without changing normal chat behavior except where the review explicitly calls out shared storage overhead. - -**Tech Stack:** Python 3.10, pytest, PyYAML, JSONL, existing A2A/pipeline modules under `/Users/ehzyo/open_repo/iac-code3/src/iac_code`, existing `uv`/Make targets. - ---- - -## File Structure - -- Create `/Users/ehzyo/open_repo/iac-code3/src/iac_code/utils/state_io.py`: review-scoped atomic write, fsync, parent-dir fsync, replace retry, and JSONL append lock helpers. -- Create `/Users/ehzyo/open_repo/iac-code3/tests/utils/test_state_io.py`: focused tests for atomic text/YAML/JSON writes, retry, parent fsync best-effort, and JSONL append serialization. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_journal.py`: durable append, durable append groups, group replay, strict tail repair preservation. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_snapshot.py`: use state I/O helper for snapshot replace, preserve public cleanup sanitization. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_stream.py`: centralized durable-event classifier, durable publication gate, durable artifact metadata handling. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_executor.py`: active sidecar mismatch error, cancel handoff event group, private-ledger cleanup handoff data. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/executor.py`: cleanup state unavailable behavior, A2A image i18n, deferred cleanup prompt handling. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/cleanup.py`: in-process ledger serialization, merge rules, tool-use mapping, corrupt-ledger fail-closed reporting, English msgids. -- Create `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/constants.py`: low-dependency cleanup prompt metadata type and cleanup event names. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/pipeline_runner.py`: sidecar save errors become hard pipeline errors, no downstream work after persistence failure, observed-resource write failure surfacing. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/session.py`: use state I/O helper for sidecar YAML files while keeping accepted two-file residual risk. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/services/session_storage.py`: atomic full-file save, opt-in cleanup prompt preservation, locked JSONL append helper, Windows-safe legacy migration. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/services/session_index.py`: shared cleanup constant and broader legacy cleanup prompt hiding. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/base.py`: restore `ToolContext` positional compatibility. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/read_file.py`: reuse cross-platform path normalization. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/cloud/base_stack.py`: do not emit empty observed resource ids. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/ui/repl.py`: damaged sidecar fallback, Windows signal fallback, cleanup scan reduction, English cleanup UI strings. -- Modify `/Users/ehzyo/open_repo/iac-code3/scripts/a2a/debugger.py`: display delivery task/context aliases and image docs alignment. -- Modify `/Users/ehzyo/open_repo/iac-code3/scripts/a2a/selling_console.py`: Windows socket reuse behavior, delivery alias display, KeyboardInterrupt shutdown, module docstring. -- Modify `/Users/ehzyo/open_repo/iac-code3/scripts/repl/e2e/run_pipeline_scenarios.py`: POSIX guard and Windows-safe command parsing. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/utils/image/store.py`: document or improve Windows privacy behavior through existing `file_security` utilities. -- Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po` and sibling catalogs only through `make translate`. -- Create `/Users/ehzyo/open_repo/iac-code3/docs/pipeline-schema-reference.md`: formal schema reference. -- Create `/Users/ehzyo/open_repo/iac-code3/scripts/repl/e2e/README.md`: English REPL E2E docs. -- Create `/Users/ehzyo/open_repo/iac-code3/docs/review-fix-summary.md`: closure matrix after implementation and verification. - -## Task 1: State I/O Foundation - -**Files:** -- Create: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/utils/state_io.py` -- Create: `/Users/ehzyo/open_repo/iac-code3/tests/utils/test_state_io.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/utils/file_security.py` - -- [ ] **Step 1: Write failing tests for atomic state writes** - -Add `/Users/ehzyo/open_repo/iac-code3/tests/utils/test_state_io.py` with these tests: - -```python -from __future__ import annotations - -import json -import os -from pathlib import Path - -import pytest - -from iac_code.utils.state_io import append_jsonl_locked, atomic_write_json, atomic_write_text - - -def test_atomic_write_text_replaces_file_and_removes_temp(tmp_path: Path) -> None: - path = tmp_path / "state.txt" - path.write_text("old", encoding="utf-8") - - atomic_write_text(path, "new", durable=True) - - assert path.read_text(encoding="utf-8") == "new" - assert not list(tmp_path.glob(".state.txt.*.tmp")) - - -def test_atomic_write_json_fails_without_overwriting_target(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - path = tmp_path / "state.json" - path.write_text('{"ok": true}\n', encoding="utf-8") - - def fail_replace(src: str, dst: str) -> None: - raise PermissionError("locked") - - monkeypatch.setattr("iac_code.utils.state_io.os.replace", fail_replace) - - with pytest.raises(PermissionError, match="locked"): - atomic_write_json(path, {"ok": False}, durable=True, replace_attempts=1) - - assert path.read_text(encoding="utf-8") == '{"ok": true}\n' - assert not list(tmp_path.glob(".state.json.*.tmp")) - - -def test_append_jsonl_locked_writes_one_complete_line_per_record(tmp_path: Path) -> None: - path = tmp_path / "session.jsonl" - - append_jsonl_locked(path, [{"a": 1}, {"b": 2}], durable=False) - - lines = path.read_text(encoding="utf-8").splitlines() - assert [json.loads(line) for line in lines] == [{"a": 1}, {"b": 2}] - - -def test_parent_directory_fsync_is_best_effort(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - path = tmp_path / "state.txt" - calls: list[int] = [] - original_fsync = os.fsync - - def flaky_fsync(fd: int) -> None: - calls.append(fd) - if len(calls) > 1: - raise OSError("directory fsync unsupported") - original_fsync(fd) - - monkeypatch.setattr("iac_code.utils.state_io.os.fsync", flaky_fsync) - - atomic_write_text(path, "ok", durable=True) - - assert path.read_text(encoding="utf-8") == "ok" -``` - -- [ ] **Step 2: Run the new state I/O tests and verify they fail** - -Run: - -```bash -uv run pytest tests/utils/test_state_io.py -q -``` - -Expected: FAIL because `iac_code.utils.state_io` does not exist. - -- [ ] **Step 3: Implement the state I/O helper** - -Create `/Users/ehzyo/open_repo/iac-code3/src/iac_code/utils/state_io.py`: - -```python -"""Durable state-file I/O helpers for recovery-critical files.""" - -from __future__ import annotations - -import json -import os -import sys -import tempfile -import threading -import time -from collections.abc import Iterable -from contextlib import contextmanager -from pathlib import Path -from typing import Any, Iterator - -_PATH_LOCKS: dict[Path, threading.RLock] = {} -_PATH_LOCKS_LOCK = threading.Lock() - - -def _path_lock(path: Path) -> threading.RLock: - resolved = path.resolve() - with _PATH_LOCKS_LOCK: - lock = _PATH_LOCKS.get(resolved) - if lock is None: - lock = threading.RLock() - _PATH_LOCKS[resolved] = lock - return lock - - -def safe_replace(src: str | Path, dst: str | Path, *, attempts: int = 3, delay: float = 0.05) -> None: - for attempt in range(attempts): - try: - os.replace(src, dst) - return - except PermissionError: - if attempt >= attempts - 1: - raise - time.sleep(delay * (attempt + 1)) - - -def fsync_parent_dir(path: Path) -> None: - if sys.platform == "win32": - return - try: - fd = os.open(str(path.parent), os.O_RDONLY) - except OSError: - return - try: - try: - os.fsync(fd) - except OSError: - return - finally: - os.close(fd) - - -def atomic_write_bytes( - path: str | Path, - content: bytes, - *, - durable: bool = True, - replace_attempts: int = 3, -) -> None: - target = Path(path) - target.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)) - tmp_path = Path(tmp_name) - try: - with os.fdopen(fd, "wb") as handle: - handle.write(content) - handle.flush() - if durable: - os.fsync(handle.fileno()) - safe_replace(tmp_path, target, attempts=replace_attempts) - if durable: - fsync_parent_dir(target) - except Exception: - try: - tmp_path.unlink() - except FileNotFoundError: - pass - raise - - -def atomic_write_text( - path: str | Path, - content: str, - *, - encoding: str = "utf-8", - durable: bool = True, - replace_attempts: int = 3, -) -> None: - atomic_write_bytes(path, content.encode(encoding), durable=durable, replace_attempts=replace_attempts) - - -def atomic_write_json( - path: str | Path, - value: Any, - *, - durable: bool = True, - replace_attempts: int = 3, -) -> None: - content = json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) + "\n" - atomic_write_text(path, content, durable=durable, replace_attempts=replace_attempts) - - -@contextmanager -def _cross_process_append_lock(path: Path) -> Iterator[None]: - lock_path = path.with_name(f".{path.name}.lock") - lock_path.parent.mkdir(parents=True, exist_ok=True) - with lock_path.open("a+b") as lock_file: - if sys.platform == "win32": - import msvcrt - - try: - msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) - except OSError as exc: - raise RuntimeError(f"could not acquire append lock for {path}") from exc - try: - yield - finally: - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) - else: - import fcntl - - try: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - except OSError as exc: - raise RuntimeError(f"could not acquire append lock for {path}") from exc - try: - yield - finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - - -def append_jsonl_locked( - path: str | Path, - records: Iterable[dict[str, Any]], - *, - durable: bool = False, -) -> None: - target = Path(path) - target.parent.mkdir(parents=True, exist_ok=True) - lines = [json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n" for record in records] - if not lines: - return - with _path_lock(target): - with _cross_process_append_lock(target): - with target.open("ab") as handle: - for line in lines: - handle.write(line.encode("utf-8")) - handle.flush() - if durable: - os.fsync(handle.fileno()) -``` - -- [ ] **Step 4: Re-export compatible helpers where existing code imports `file_security.safe_replace`** - -Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/utils/file_security.py` so `safe_replace` delegates to `state_io.safe_replace` and `atomic_write_text` delegates to `state_io.atomic_write_text`: - -```python -from iac_code.utils.state_io import atomic_write_text as durable_atomic_write_text -from iac_code.utils.state_io import safe_replace as durable_safe_replace - - -def safe_replace(src: str, dst: str) -> None: - """os.replace with retry for Windows file locking.""" - durable_safe_replace(src, dst) - - -def atomic_write_text(path: Path, content: str, *, encoding: str = "utf-8") -> None: - """Atomically replace *path* with text content.""" - durable_atomic_write_text(path, content, encoding=encoding, durable=True) -``` - -- [ ] **Step 5: Run state I/O tests** - -Run: - -```bash -uv run pytest tests/utils/test_state_io.py tests/services/capabilities/test_auto_detect.py -q -``` - -Expected: PASS. - -- [ ] **Step 6: Commit Task 1** - -Run: - -```bash -git add src/iac_code/utils/state_io.py src/iac_code/utils/file_security.py tests/utils/test_state_io.py -git commit -m "fix: add durable state file helpers" -``` - -## Task 2: Session Storage Durability And Compatibility - -**Files:** -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/services/session_storage.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/constants.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/cleanup.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/services/session_index.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/services/test_session_storage.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/services/test_session_index.py` - -- [ ] **Step 1: Write failing SessionStorage tests** - -Append to `/Users/ehzyo/open_repo/iac-code3/tests/services/test_session_storage.py`: - -```python -from iac_code.agent.message import Message - - -def test_save_does_not_scan_old_file_unless_preserving_cleanup_prompts(tmp_path, monkeypatch): - storage = SessionStorage(projects_dir=tmp_path) - storage.append("/tmp/project", "sid", Message(role="user", content="old")) - - def fail_load(cwd, session_id): - raise AssertionError("save should not load existing messages") - - monkeypatch.setattr(storage, "load", fail_load) - - storage.save("/tmp/project", "sid", [Message(role="user", content="new")]) - - assert [message.content for message in SessionStorage(projects_dir=tmp_path).load("/tmp/project", "sid")] == ["new"] - - -def test_save_can_preserve_cleanup_prompts_when_requested(tmp_path): - storage = SessionStorage(projects_dir=tmp_path) - cleanup = create_cleanup_prompt_message("cleanup stack-123", cleanup_ledger_path=tmp_path / "cleanup.yaml") - storage.append("/tmp/project", "sid", cleanup) - - storage.save( - "/tmp/project", - "sid", - [Message(role="user", content="new")], - preserve_cleanup_prompts=True, - ) - - loaded = SessionStorage(projects_dir=tmp_path).load("/tmp/project", "sid") - assert [message.content for message in loaded] == ["new", "cleanup stack-123"] - - -def test_append_uses_locked_jsonl_helper(tmp_path, monkeypatch): - storage = SessionStorage(projects_dir=tmp_path) - calls = [] - - def fake_append(path, records, *, durable=False): - calls.append((path.name, list(records), durable)) - - monkeypatch.setattr("iac_code.services.session_storage.append_jsonl_locked", fake_append) - - storage.append("/tmp/project", "sid", Message(role="user", content="hello"), git_branch="main") - - assert calls[0][0] == "session.jsonl" - assert calls[0][1][0]["content"] == "hello" - assert calls[0][1][0]["git_branch"] == "main" - - -def test_legacy_migration_keeps_directory_session_when_present(tmp_path): - storage = SessionStorage(projects_dir=tmp_path) - directory = storage.session_dir("/tmp/project", "sid") - directory.mkdir(parents=True) - directory_path = directory / "session.jsonl" - directory_path.write_text('{"role":"user","content":"directory"}\n', encoding="utf-8") - legacy_path = storage.legacy_session_path("/tmp/project", "sid") - legacy_path.parent.mkdir(parents=True, exist_ok=True) - legacy_path.write_text('{"role":"user","content":"legacy"}\n', encoding="utf-8") - - assert storage._ensure_directory_format("/tmp/project", "sid") == directory - - assert directory_path.read_text(encoding="utf-8") == '{"role":"user","content":"directory"}\n' -``` - -- [ ] **Step 2: Run SessionStorage tests and verify failure** - -Run: - -```bash -uv run pytest tests/services/test_session_storage.py::test_save_does_not_scan_old_file_unless_preserving_cleanup_prompts tests/services/test_session_storage.py::test_save_can_preserve_cleanup_prompts_when_requested tests/services/test_session_storage.py::test_append_uses_locked_jsonl_helper tests/services/test_session_storage.py::test_legacy_migration_keeps_directory_session_when_present -q -``` - -Expected: FAIL because `preserve_cleanup_prompts` and locked append are not implemented. - -- [ ] **Step 3: Add shared cleanup constants** - -Create `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/constants.py`: - -```python -"""Low-dependency pipeline engine constants.""" - -CLEANUP_PROMPT_METADATA_TYPE = "pipeline_cleanup_prompt" - -PIPELINE_EVENT_CLEANUP_STARTED = "cleanup_started" -PIPELINE_EVENT_CLEANUP_PROGRESS = "cleanup_progress" -PIPELINE_EVENT_CLEANUP_COMPLETED = "cleanup_completed" -PIPELINE_EVENT_CLEANUP_FAILED = "cleanup_failed" -``` - -Update cleanup and session index imports: - -```python -from iac_code.pipeline.engine.constants import CLEANUP_PROMPT_METADATA_TYPE -``` - -- [ ] **Step 4: Update SessionStorage write paths** - -Modify `/Users/ehzyo/open_repo/iac-code3/src/iac_code/services/session_storage.py`: - -```python -from iac_code.utils.state_io import append_jsonl_locked, atomic_write_text, safe_replace -``` - -Change `append()` and `append_meta()` to call `append_jsonl_locked(path, [data])` and `append_jsonl_locked(path, [entry])`. - -Change `save()` signature and body: - -```python -def save( - self, - cwd: str, - session_id: str, - messages: list[Message], - *, - git_branch: str | None = None, - preserve_cleanup_prompts: bool = False, -) -> None: - """Overwrite the session file with the given messages.""" - if preserve_cleanup_prompts: - messages = self._merge_preserved_cleanup_prompts(cwd, session_id, messages) - path = self._session_path(cwd, session_id) - ensure_private_dir(path.parent) - lines = [] - for msg in messages: - data = self._stamp(msg.to_dict(), cwd, session_id, git_branch) - lines.append(json.dumps(data, ensure_ascii=False) + "\n") - atomic_write_text(path, "".join(lines), durable=True) - ensure_private_file(path) -``` - -Change `_ensure_directory_format()` legacy migration: - -```python -if directory_path.exists(): - return session_dir -if not legacy_path.exists(): - ensure_private_dir(session_dir) - directory_path.touch() - ensure_private_file(directory_path) - return session_dir -ensure_private_dir(session_dir) -safe_replace(str(legacy_path), str(directory_path)) -ensure_private_file(directory_path) -return session_dir -``` - -- [ ] **Step 5: Update call sites that intentionally preserve cleanup prompts** - -Search: - -```bash -rg -n "save\\(.*messages|\\.save\\(" src/iac_code tests | rg "session_storage|SessionStorage" -``` - -For flows that rewrite/compact context while retaining hidden cleanup prompts, pass `preserve_cleanup_prompts=True`. Leave normal turn saves at the default `False`. - -- [ ] **Step 6: Broaden legacy cleanup prompt hiding** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/services/session_index.py`, replace Chinese-only detection with metadata-first plus conservative legacy substrings: - -```python -_LEGACY_CLEANUP_PROMPT_MARKERS = ( - "pipeline rollback", - "rollback cleanup", - "cleanup required", - "待清理资源", - "回滚残留资源", - "严格白名单", -) - - -def _is_cleanup_prompt_message(message: Message) -> bool: - metadata = message.metadata - if isinstance(metadata, dict) and metadata.get("type") == CLEANUP_PROMPT_METADATA_TYPE: - return True - content = message.content - if not isinstance(content, str): - return False - lowered = content.lower() - return any(marker.lower() in lowered for marker in _LEGACY_CLEANUP_PROMPT_MARKERS) -``` - -- [ ] **Step 7: Run SessionStorage and session index tests** - -Run: - -```bash -uv run pytest tests/services/test_session_storage.py tests/services/test_session_index.py tests/agent/test_agent_loop_continue.py -q -``` - -Expected: PASS. - -- [ ] **Step 8: Commit Task 2** - -Run: - -```bash -git add src/iac_code/services/session_storage.py src/iac_code/services/session_index.py src/iac_code/pipeline/engine/constants.py src/iac_code/pipeline/engine/cleanup.py tests/services/test_session_storage.py tests/services/test_session_index.py -git commit -m "fix: harden session storage writes" -``` - -## Task 3: A2A Journal And Publisher Durability - -**Files:** -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_journal.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_snapshot.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_stream.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_pipeline_journal.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_pipeline_stream.py` - -- [ ] **Step 1: Write failing journal group tests** - -Append to `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_pipeline_journal.py`: - -```python -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"] -``` - -- [ ] **Step 2: Write failing publisher durability tests** - -Append to `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_pipeline_stream.py`: - -```python -@pytest.mark.asyncio -async def test_recovery_semantic_event_is_not_enqueued_when_metadata_persistence_fails(tmp_path: Path, monkeypatch): - publisher, queue = _publisher(tmp_path) - - def fail_append(event, durable=False): - 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): - publisher, queue = _publisher(tmp_path) - - def fail_append(event, durable=False): - if durable: - raise OSError("journal locked") - publisher.journal.__class__.append(publisher.journal, event, durable=durable) - - 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 -``` - -- [ ] **Step 3: Run failing A2A durability tests** - -Run: - -```bash -uv run pytest tests/a2a/test_pipeline_journal.py::test_append_many_replays_group_as_events tests/a2a/test_pipeline_journal.py::test_append_many_sorts_group_events_with_regular_events tests/a2a/test_pipeline_stream.py::test_recovery_semantic_event_is_not_enqueued_when_metadata_persistence_fails tests/a2a/test_pipeline_stream.py::test_text_delta_can_be_enqueued_when_only_durable_metadata_fails -q -``` - -Expected: FAIL because `append_many()` and durable classification are not implemented. - -- [ ] **Step 4: Implement durable journal append and group replay** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_journal.py`, change `append()` to: - -```python -def append(self, event: dict[str, Any], durable: bool = False) -> None: - self.pipeline_dir.mkdir(parents=True, exist_ok=True) - safe_event = to_json_safe(event) - try: - line = json.dumps(safe_event, ensure_ascii=False, separators=(",", ":"), allow_nan=False) - except (TypeError, ValueError): - logger.warning("Skipping non-JSON-safe A2A pipeline journal event in %s", self.path, exc_info=True) - return - with self.path.open("a", encoding="utf-8") as handle: - handle.write(line + "\n") - handle.flush() - if durable: - os.fsync(handle.fileno()) -``` - -Add `append_many()`: - -```python -def append_many(self, events: list[dict[str, Any]], durable: bool = False) -> None: - self.pipeline_dir.mkdir(parents=True, exist_ok=True) - 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 = { - "__iac_code_record_type": "event_group", - "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()) -``` - -In `_read_all()`, when a parsed object has `__iac_code_record_type == "event_group"` and `events` is a list of dicts, extend `events` with those child events instead of appending the group record. - -- [ ] **Step 5: Use durable state I/O for snapshots** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_snapshot.py`, replace temp write plus `tmp_path.replace(self.path)` with: - -```python -from iac_code.utils.state_io import atomic_write_json -``` - -and: - -```python -atomic_write_json(self.path, next_snapshot, durable=True) -return True -``` - -- [ ] **Step 6: Add A2A durable-event classifier** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_stream.py`, add: - -```python -_RECOVERY_SEMANTIC_EVENT_TYPES = { - "pipeline_started", - "step_started", - "step_completed", - "step_failed", - "candidate_selected", - "candidate_completed", - "candidate_failed", - "input_required", - "pipeline_completed", - "pipeline_failed", - "pipeline_canceled", - "pipeline_handoff_ready", - "cleanup_started", - "cleanup_progress", - "cleanup_completed", - "cleanup_failed", - "artifact_created", - "rollback_completed", - "candidate_restart_requested", -} - - -def _is_recovery_semantic_event(envelope: dict[str, Any]) -> bool: - event_type = envelope.get("eventType") - if event_type in _RECOVERY_SEMANTIC_EVENT_TYPES: - return True - if envelope.get("scope") in {"step", "candidate", "candidateStep"} and envelope.get("status") in { - "working", - "waiting_input", - "completed", - "failed", - "canceled", - }: - return True - return False -``` - -Then in `_persist_and_enqueue()` set: - -```python -durable_required = require_durable_metadata or _is_recovery_semantic_event(safe_envelope) -``` - -Call: - -```python -self.journal.append(safe_envelope, durable=durable_required) -``` - -and gate queue delivery with `durable_required` instead of only `require_durable_metadata`. - -- [ ] **Step 7: Run A2A durability tests** - -Run: - -```bash -uv run pytest tests/a2a/test_pipeline_journal.py tests/a2a/test_pipeline_stream.py -q -``` - -Expected: PASS. - -- [ ] **Step 8: Commit Task 3** - -Run: - -```bash -git add src/iac_code/a2a/pipeline_journal.py src/iac_code/a2a/pipeline_snapshot.py src/iac_code/a2a/pipeline_stream.py tests/a2a/test_pipeline_journal.py tests/a2a/test_pipeline_stream.py -git commit -m "fix: make A2A recovery events durable" -``` - -## Task 4: A2A Recovery, Active Mismatch, And Handoff Cleanup - -**Files:** -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_executor.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/executor.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/scripts/a2a/debugger.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/scripts/a2a/selling_console.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_pipeline_executor.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_executor_cleanup.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_pipeline_debugger_script.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_selling_console_script.py` - -- [ ] **Step 1: Write active mismatch test** - -Add to `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_pipeline_executor.py`: - -```python -def test_active_sidecar_mismatch_returns_recoverable_error_without_clearing(tmp_path): - 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", - } -``` - -- [ ] **Step 2: Write cancel handoff atomicity test** - -Modify the existing `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_pipeline_executor.py::test_canceled_pipeline_run_closes_blocked_stream_without_child_task_leak` so it records `append_many()` calls while preserving the existing assertions: - -```python -append_many_calls = [] -original_append_many = A2APipelineJournal.append_many - - -def recording_append_many(self, events, durable=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) -``` - -At the end of that test, after the existing journal and snapshot assertions, add: - -```python -assert append_many_calls[-1] == (["pipeline_canceled", "pipeline_handoff_ready"], True) -``` - -- [ ] **Step 3: Write cleanup source-of-truth tests** - -Extend `/Users/ehzyo/open_repo/iac-code3/tests/a2a/test_executor_cleanup.py`: - -```python -def test_a2a_handoff_does_not_reconstruct_cleanup_prompt_from_public_snapshot(tmp_path): - snapshot = { - "cleanup": { - "resources": [{"provider": "ros", "resourceId": "stack-123", "resourceType": "stack"}], - "status": "pending", - } - } - - cleanup = _cleanup_payload_from_private_ledger_or_unavailable( - ledger_path=tmp_path / "missing-cleanup.yaml", - public_snapshot=snapshot, - ) - - assert cleanup["status"] == "unavailable" - assert "prompt" not in cleanup - assert "resources" not in cleanup -``` - -- [ ] **Step 4: Run targeted A2A recovery tests and verify failure** - -Run: - -```bash -uv run pytest tests/a2a/test_pipeline_executor.py tests/a2a/test_executor_cleanup.py -q -``` - -Expected: FAIL on the new tests. - -- [ ] **Step 5: Implement active mismatch error** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/pipeline_executor.py`, add an invalid-params helper using the repository's existing JSON-RPC error type. Use the same class/import already used by the executor for invalid params. The helper must produce: - -```python -{ - "recoverableTaskId": recoverable_task_id, - "contextId": context_id, - "sidecarStatus": sidecar_status, -} -``` - -Replace `_fresh_pipeline_after_sidecar_mismatch()` calls for `running` and `waiting_input` sidecars with returning this error to the A2A request path. Keep terminal/non-resumable cleanup behavior unchanged. - -- [ ] **Step 6: Implement cancel handoff durable group** - -Replace: - -```python -journal.append(envelope) -if handoff_envelope is not None: - journal.append(handoff_envelope) -snapshot_store.save(reduce_pipeline_events(journal.read_all_repairing_tail())) -``` - -with: - -```python -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())) -``` - -- [ ] **Step 7: Implement private-ledger cleanup handoff source of truth** - -Add a helper in `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/executor.py`: - -```python -def _cleanup_payload_from_private_ledger_or_unavailable( - *, - ledger_path: Path, - public_snapshot: dict[str, Any] | None = None, -) -> dict[str, Any]: - ledger = CleanupLedger(ledger_path) - if ledger.load_failed() or not ledger_path.exists(): - 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), - } -``` - -Use this helper in normal and cancel handoff paths. Keep public snapshot resource summaries for display only. - -- [ ] **Step 8: Surface recoverable task ids in scripts** - -In `/Users/ehzyo/open_repo/iac-code3/scripts/a2a/debugger.py` and `/Users/ehzyo/open_repo/iac-code3/scripts/a2a/selling_console.py`, when JSON-RPC error data contains `recoverableTaskId`, print or render it next to the error message. Add script tests that parse a response shaped like: - -```python -{"error": {"code": -32602, "message": "Pipeline already running.", "data": {"recoverableTaskId": "task-owner", "contextId": "ctx-1", "sidecarStatus": "running"}}} -``` - -and assert `"task-owner"` is shown. - -- [ ] **Step 9: Run A2A recovery tests** - -Run: - -```bash -uv run pytest tests/a2a/test_pipeline_executor.py tests/a2a/test_executor_cleanup.py tests/a2a/test_pipeline_debugger_script.py tests/a2a/test_selling_console_script.py -q -``` - -Expected: PASS. - -- [ ] **Step 10: Commit Task 4** - -Run: - -```bash -git add src/iac_code/a2a/pipeline_executor.py src/iac_code/a2a/executor.py scripts/a2a/debugger.py scripts/a2a/selling_console.py tests/a2a/test_pipeline_executor.py tests/a2a/test_executor_cleanup.py tests/a2a/test_pipeline_debugger_script.py tests/a2a/test_selling_console_script.py -git commit -m "fix: preserve A2A recoverable pipeline state" -``` - -## Task 5: Cleanup Ledger Correctness - -**Files:** -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/cleanup.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/pipeline_runner.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/selling/hooks/deploying.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/pipeline/engine/test_cleanup.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/pipeline/engine/test_pipeline_runner_cleanup.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/pipeline/selling/test_deploying_cleanup_hook.py` - -- [ ] **Step 1: Write failing cleanup merge tests** - -Append to `/Users/ehzyo/open_repo/iac-code3/tests/pipeline/engine/test_cleanup.py`: - -```python -def test_mark_cleanup_required_preserves_active_execution_fields(tmp_path) -> None: - ledger = CleanupLedger(tmp_path / "cleanup.yaml") - resource = CleanupResource.from_observed(_observed_stack(), reason="rollback requested") - ledger.mark_cleanup_required([resource], source_step_id="deploying", reason="rollback requested") - ledger.update_resource( - provider="ros", - resource_type="stack", - resource_id="stack-123", - region_id="cn-hangzhou", - cleanup_status="in_progress", - cleanup_tool_use_id="toolu-delete", - cleanup_action="DeleteStack", - progress_status="DELETE_IN_PROGRESS", - progress_percentage=30, - last_error="slow", - ) - - ledger.mark_cleanup_required([resource], source_step_id="deploying", reason="rollback requested again") - - [updated] = ledger.cleanup_resources() - assert updated.cleanup_status == "in_progress" - assert updated.cleanup_tool_use_id == "toolu-delete" - assert updated.cleanup_action == "DeleteStack" - assert updated.progress_status == "DELETE_IN_PROGRESS" - assert updated.progress_percentage == 30 - assert updated.last_error == "slow" - - -def test_observer_uses_persisted_tool_mapping_after_restart(tmp_path) -> None: - ledger = CleanupLedger(tmp_path / "cleanup.yaml") - resource = CleanupResource.from_observed(_observed_stack(), reason="rollback requested") - ledger.mark_cleanup_required([resource], source_step_id="deploying", reason="rollback requested") - CleanupObserver(ledger).observe( - ToolUseEndEvent( - tool_use_id="toolu-delete", - name="ros_stack", - input={"action": "DeleteStack", "region_id": "cn-hangzhou", "params": {"StackId": "stack-123"}}, - ) - ) - - restarted = CleanupObserver(CleanupLedger(tmp_path / "cleanup.yaml")) - restarted.observe( - ToolResultEvent( - tool_use_id="toolu-delete", - tool_name="ros_stack", - result=json.dumps({"stack_id": "stack-123", "status": "DELETE_COMPLETE"}), - is_error=False, - ) - ) - - [updated] = CleanupLedger(tmp_path / "cleanup.yaml").cleanup_resources() - assert updated.cleanup_status == "completed" -``` - -- [ ] **Step 2: Write corrupt ledger fail-closed tests** - -Append: - -```python -def test_corrupt_ledger_records_unavailable_without_overwrite(tmp_path) -> None: - path = tmp_path / "cleanup.yaml" - path.write_text("[broken", encoding="utf-8") - ledger = CleanupLedger(path) - - ledger.mark_cleanup_required([CleanupResource.from_observed(_observed_stack(), reason="rollback")], source_step_id="deploying", reason="rollback") - - assert path.read_text(encoding="utf-8") == "[broken" - assert ledger.load_failed() - assert ledger.load_error() -``` - -- [ ] **Step 3: Run cleanup tests and verify failure** - -Run: - -```bash -uv run pytest tests/pipeline/engine/test_cleanup.py::test_mark_cleanup_required_preserves_active_execution_fields tests/pipeline/engine/test_cleanup.py::test_observer_uses_persisted_tool_mapping_after_restart tests/pipeline/engine/test_cleanup.py::test_corrupt_ledger_records_unavailable_without_overwrite -q -``` - -Expected: FAIL on active-field preservation and persisted mapping. - -- [ ] **Step 4: Add in-process ledger serialization and state I/O save** - -In `CleanupLedger`, use a per-path `threading.RLock` before every load-modify-save path. Add a `_with_write_lock()` helper or wrap `record_observed()`, `mark_cleanup_required()`, `update_resource()`, and `record_prompt_queued()` bodies. Replace `_save()` with: - -```python -from iac_code.utils.state_io import atomic_write_text - - -def _save(self, data: dict[str, Any]) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - content = yaml.safe_dump(data, allow_unicode=True, sort_keys=False) - atomic_write_text(self.path, content, durable=True) -``` - -- [ ] **Step 5: Implement monotonic merge rules** - -In `mark_cleanup_required()`, replace the direct `replace(resource, cleanup_required=True, cleanup_reason=resource.cleanup_reason or reason, source_step_id=resource.source_step_id or source_step_id, updated_at=now)` assignment with a merge helper: - -```python -def _merge_cleanup_required(existing: CleanupResource | None, incoming: CleanupResource, *, reason: str, source_step_id: str, now: float) -> CleanupResource: - if existing is None: - return replace( - incoming, - cleanup_required=True, - cleanup_reason=incoming.cleanup_reason or reason, - source_step_id=incoming.source_step_id or source_step_id, - updated_at=now, - ) - if existing.cleanup_status in _TERMINAL_CLEANUP_STATUSES: - return existing - active_status = existing.cleanup_status if existing.cleanup_status in _ACTIVE_CLEANUP_STATUSES or existing.cleanup_status == "failed" else incoming.cleanup_status - return replace( - incoming, - cleanup_required=True, - cleanup_reason=incoming.cleanup_reason or existing.cleanup_reason or reason, - source_step_id=incoming.source_step_id or existing.source_step_id or source_step_id, - cleanup_status=active_status, - cleanup_tool_use_id=existing.cleanup_tool_use_id, - cleanup_action=existing.cleanup_action, - progress_status=existing.progress_status, - progress_percentage=existing.progress_percentage, - last_error=existing.last_error, - observed_at=existing.observed_at or incoming.observed_at, - updated_at=now, - ) -``` - -- [ ] **Step 6: Persist tool-use mappings** - -Add ledger `tool_uses` data with sanitized input summaries: - -```python -def record_tool_use_mapping(self, *, tool_use_id: str, provider: str, resource_type: str, resource_id: str, region_id: str, action: str, tool_name: str, tool_input: dict[str, Any]) -> None: - data = self._load_for_write() - if data is None: - return - mappings = {str(item.get("tool_use_id")): dict(item) for item in _dict_list(data.get("tool_uses"))} - mappings[tool_use_id] = { - "tool_use_id": tool_use_id, - "provider": provider, - "resource_type": resource_type, - "resource_id": resource_id, - "region_id": region_id, - "action": action, - "tool_name": tool_name, - "input_summary": _safe_history_error(json.dumps(tool_input, ensure_ascii=False, sort_keys=True)), - } - data["tool_uses"] = list(mappings.values()) - self._save(data) -``` - -In `CleanupObserver._observe_tool_use()`, call `record_tool_use_mapping()` for `DeleteStack` and `GetStack`. In `_observe_tool_result()`, if `_tool_inputs` misses the id, load `ledger.tool_use_mapping(tool_use_id)` and use that for matching. - -- [ ] **Step 7: Add cleanup unavailable history warning** - -When no mapping exists for a cleanup tool result, append a history entry: - -```python -{ - "type": "cleanup_tool_result_unmatched", - "tool_use_id": event.tool_use_id, - "tool_name": event.tool_name, - "timestamp": time.time(), -} -``` - -- [ ] **Step 8: Keep cloud observation window residual but surface write failures** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/pipeline_runner.py`, when `ledger.record_observed()` raises after Task 1 state I/O changes, log warning and yield or record a recoverable pipeline error. The design accepts the API-success-to-event gap, but not silent ledger write failure. - -- [ ] **Step 9: Add deploying hook warning** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/selling/hooks/deploying.py`, when `from_attempt_id` is falsy in cleanup-required hook code, log: - -```python -logger.warning("Skipping deploying cleanup hook because from_attempt_id is missing") -``` - -- [ ] **Step 10: Run cleanup tests** - -Run: - -```bash -uv run pytest tests/pipeline/engine/test_cleanup.py tests/pipeline/engine/test_pipeline_runner_cleanup.py tests/pipeline/selling/test_deploying_cleanup_hook.py -q -``` - -Expected: PASS. - -- [ ] **Step 11: Commit Task 5** - -Run: - -```bash -git add src/iac_code/pipeline/engine/cleanup.py src/iac_code/pipeline/engine/pipeline_runner.py src/iac_code/pipeline/selling/hooks/deploying.py tests/pipeline/engine/test_cleanup.py tests/pipeline/engine/test_pipeline_runner_cleanup.py tests/pipeline/selling/test_deploying_cleanup_hook.py -git commit -m "fix: preserve cleanup ledger state" -``` - -## Task 6: Pipeline Runner Persistence And REPL Resume Fallback - -**Files:** -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/session.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/pipeline_runner.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/ui/repl.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/pipeline/engine/test_session.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/pipeline/engine/test_pipeline_runner.py` -- Test: `/Users/ehzyo/open_repo/iac-code3/tests/ui/test_repl_pipeline_sidecar_restore.py` - -- [ ] **Step 1: Write sidecar YAML durability test** - -Add to `/Users/ehzyo/open_repo/iac-code3/tests/pipeline/engine/test_session.py`: - -```python -def test_sidecar_yaml_uses_atomic_state_write(monkeypatch, tmp_path) -> None: - calls = [] - - def fake_atomic_write_text(path, content, *, durable=True, replace_attempts=3, encoding="utf-8"): - calls.append((Path(path).name, durable)) - Path(path).write_text(content, encoding=encoding) - - monkeypatch.setattr("iac_code.pipeline.engine.session.atomic_write_text", fake_atomic_write_text) - - session = PipelineSession(tmp_path / "pipeline") - session.save_running_sync( - "step", - {"current_index": 0, "rollback_count": 0, "step_statuses": {"step": "running"}}, - {}, - {"pipeline_name": "test", "step_ids": ["step"], "sub_pipeline_step_ids": {}, "pipeline_fingerprint": "fp"}, - ) - - assert ("context.yaml", True) in calls - assert ("meta.yaml", True) in calls -``` - -- [ ] **Step 2: Write runner persistence failure tests** - -Add to `/Users/ehzyo/open_repo/iac-code3/tests/pipeline/engine/test_pipeline_runner.py`: - -```python -@pytest.mark.asyncio -async def test_sidecar_save_failure_stops_before_next_step(tmp_path): - runner = _build_two_step_runner(tmp_path) - runner.session = FailingSavePipelineSession() - - events = [] - async for event in runner.run("start"): - events.append(event) - - assert any("pipeline state persistence failed" in str(getattr(event, "data", {})).lower() for event in events) - assert runner.state_machine.current_step.step_id == "a" - assert runner.session.calls[0][0] == "running_attempted" -``` - -- [ ] **Step 3: Write damaged `/resume` metadata test** - -Add to `/Users/ehzyo/open_repo/iac-code3/tests/ui/test_repl_pipeline_sidecar_restore.py`: - -```python -@pytest.mark.asyncio -async def test_confirm_pipeline_resume_handles_corrupt_meta(tmp_path, repl_for_sidecar_restore): - meta_path = tmp_path / "meta.yaml" - meta_path.write_text("[broken", encoding="utf-8") - - choice = await repl_for_sidecar_restore._confirm_pipeline_resume(meta_path) - - assert choice == "discard" - repl_for_sidecar_restore.renderer.print_system_message.assert_called() -``` - -- [ ] **Step 4: Run targeted tests and verify failure** - -Run: - -```bash -uv run pytest tests/pipeline/engine/test_session.py::test_sidecar_yaml_uses_atomic_state_write tests/pipeline/engine/test_pipeline_runner.py::test_sidecar_save_failure_stops_before_next_step tests/ui/test_repl_pipeline_sidecar_restore.py::test_confirm_pipeline_resume_handles_corrupt_meta -q -``` - -Expected: FAIL until implementation lands. - -- [ ] **Step 5: Use state I/O helper for sidecar YAML** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/session.py`, import `atomic_write_text` and change `_atomic_write_yaml()`: - -```python -def _atomic_write_yaml(self, path: Path, data: dict) -> None: - self.session_dir.mkdir(parents=True, exist_ok=True) - content = yaml.safe_dump(data, allow_unicode=True, sort_keys=False) - atomic_write_text(path, content, durable=True) -``` - -Keep the accepted residual risk: `context.yaml` and `meta.yaml` are still separate files. - -- [ ] **Step 6: Make sidecar save failures hard errors** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/pipeline_runner.py`, add: - -```python -class PipelineStatePersistenceError(RuntimeError): - """Raised when recovery-critical pipeline state cannot be persisted.""" -``` - -Change `_try_save_sidecar()` and `_try_save_sidecar_sync()` to raise `PipelineStatePersistenceError("pipeline state persistence failed during {operation}")` after recording observability. Update callers so the async run loop catches this error, yields a failure event shaped like this, and breaks before advancing, handoff, or downstream tools: - -```python -PipelineEvent( - type=PipelineEventType.STEP_FAILED, - step_id=self.state_machine.current_step.step_id, - timestamp=time.time(), - data={ - "error": "Pipeline state persistence failed.", - "error_summary": "Pipeline state persistence failed.", - "error_details": {"type": "PipelineStatePersistenceError"}, - }, -) -``` - -- [ ] **Step 7: Add persistence boundary around advance/rollback** - -For code paths around `state_machine.advance()`, `state_machine.rollback()`, interrupt rollback, and normal handoff, ensure either: - -```python -await self._save_after_advance(step.step_id) -``` - -or the corresponding rollback save is awaited immediately after the in-memory mutation, and failure stops event emission. Do not emit `STEP_COMPLETED`, `PIPELINE_COMPLETED`, `pipeline_handoff_ready`, or execute the next step when the save raises. - -- [ ] **Step 8: Handle damaged `/resume` metadata** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/ui/repl.py`, replace raw `_yaml.safe_load(meta_path.read_text(encoding="utf-8"))` with: - -```python -try: - loaded = _yaml.safe_load(meta_path.read_text(encoding="utf-8")) -except (FileNotFoundError, OSError, UnicodeDecodeError, _yaml.YAMLError) as exc: - self.renderer.print_system_message( - _("Could not read pipeline state metadata: {reason}").format(reason=str(exc) or type(exc).__name__), - style="yellow", - ) - return "discard" -if loaded is None: - loaded = {} -if not isinstance(loaded, dict): - self.renderer.print_system_message(_("Pipeline state metadata is invalid; continuing as normal chat."), style="yellow") - return "discard" -meta = loaded -``` - -- [ ] **Step 9: Run runner and REPL tests** - -Run: - -```bash -uv run pytest tests/pipeline/engine/test_session.py tests/pipeline/engine/test_pipeline_runner.py tests/ui/test_repl_pipeline_sidecar_restore.py -q -``` - -Expected: PASS. - -- [ ] **Step 10: Commit Task 6** - -Run: - -```bash -git add src/iac_code/pipeline/engine/session.py src/iac_code/pipeline/engine/pipeline_runner.py src/iac_code/ui/repl.py tests/pipeline/engine/test_session.py tests/pipeline/engine/test_pipeline_runner.py tests/ui/test_repl_pipeline_sidecar_restore.py -git commit -m "fix: stop on pipeline state persistence failure" -``` - -## Task 7: Windows Compatibility, i18n, And Minor Code Closures - -**Files:** -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/base.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/read_file.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/cloud/base_stack.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/a2a/executor.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/user_input.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/completion_guard_state.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/engine/step_executor.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/agent/agent_loop.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/src/iac_code/ui/repl.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/scripts/a2a/selling_console.py` -- Modify: `/Users/ehzyo/open_repo/iac-code3/scripts/repl/e2e/run_pipeline_scenarios.py` -- Test: existing focused test files under `/Users/ehzyo/open_repo/iac-code3/tests` - -- [ ] **Step 1: Write ToolContext compatibility test** - -Append to `/Users/ehzyo/open_repo/iac-code3/tests/tools/test_read_file.py` or create `/Users/ehzyo/open_repo/iac-code3/tests/tools/test_tool_context.py`: - -```python -from iac_code.tools.base import ToolContext - - -def test_tool_context_positional_tool_use_id_compatibility() -> None: - context = ToolContext("/tmp/project", None, "toolu-1") - - assert context.cwd == "/tmp/project" - assert context.event_queue is None - assert context.tool_use_id == "toolu-1" -``` - -- [ ] **Step 2: Write Windows path normalization test** - -Add to `/Users/ehzyo/open_repo/iac-code3/tests/tools/test_read_file.py`: - -```python -def test_path_is_under_windows_case_insensitive(monkeypatch): - monkeypatch.setattr("iac_code.tools.path_safety.sys.platform", "win32") - from iac_code.tools.read_file import _path_is_under - - assert _path_is_under("C:\\Users\\Alice\\project\\file.txt", "c:/users/alice/project") -``` - -- [ ] **Step 3: Write empty stack id test** - -Add to `/Users/ehzyo/open_repo/iac-code3/tests/tools/cloud/test_base_stack.py`: - -```python -@pytest.mark.asyncio -async def test_create_stack_does_not_emit_observed_resource_for_empty_stack_id(): - queue = asyncio.Queue() - tool = FakeStackTool() - tool.call_action = AsyncMock(return_value="") - - await tool.execute(tool_input={"action": "CreateStack", "params": {}}, context=ToolContext(cwd="/tmp", event_queue=queue, tool_use_id="toolu-1")) - - assert queue.empty() -``` - -- [ ] **Step 4: Implement ToolContext positional order** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/base.py`, reorder fields: - -```python -cwd: str = field(default_factory=os.getcwd) -event_queue: asyncio.Queue | None = None -tool_use_id: str | None = None -additional_directories: list[str] = field(default_factory=list) -trusted_read_directories: list[str] = field(default_factory=list) -relative_read_directories: list[str] = field(default_factory=list) -pipeline_mode: bool = False -``` - -- [ ] **Step 5: Implement read path normalization** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/read_file.py`, replace `_path_is_under()` with: - -```python -from iac_code.tools.path_safety import _normalize_for_platform - - -def _path_is_under(path: str, root: str) -> bool: - try: - path_real = os.path.realpath(path) - root_real = os.path.realpath(root) - common = os.path.commonpath([path_real, root_real]) - except ValueError: - return False - return _normalize_for_platform(common) == _normalize_for_platform(root_real) -``` - -- [ ] **Step 6: Guard empty stack id** - -In `/Users/ehzyo/open_repo/iac-code3/src/iac_code/tools/cloud/base_stack.py`, change: - -```python -if context.event_queue is not None and action == "CreateStack": -``` - -to: - -```python -if context.event_queue is not None and action == "CreateStack" and stack_id: -``` - -- [ ] **Step 7: Fix i18n strings** - -Update A2A image errors and pipeline image placeholder: - -```python -_("Current model {model} does not support image input.").format(model=self._model) -_("[Image input]") -``` - -Replace Chinese cleanup msgids in source with English msgids and move Chinese translations into `messages.po` through `make translate`. Use `.format()` for interpolated translated strings. - -- [ ] **Step 8: Complete minor code closures** - -Make these exact edits: - -- In `completion_guard_state.py`, log JSON parse failures with `logger.warning("Failed to parse completion guard state", exc_info=True)` and rebuild failures with `logger.warning("Failed to rebuild completion guard state", exc_info=True)`. -- In `step_executor.py`, replace `precompleted_tools_set.update(precompleted_tools)` with `precompleted_tools_set.update(precompleted_tools.keys())`. -- In `agent_loop.py`, remove the duplicate `_pipeline_mode` assignment. -- In `scripts/a2a/selling_console.py`, set `allow_reuse_address = sys.platform != "win32"` on the HTTP server class or use the platform exclusive option if already available. -- In `scripts/a2a/selling_console.py`, add a module docstring describing text-only input support. -- In `scripts/repl/e2e/run_pipeline_scenarios.py`, guard real PTY execution on Windows with `SystemExit("real PTY REPL E2E is POSIX-only")`. -- In `scripts/repl/e2e/run_pipeline_scenarios.py`, use `shlex.split(value, posix=(os.name != "nt"))`. -- In `ui/repl.py`, wrap `loop.add_signal_handler` with an unsupported-platform fallback that keeps existing KeyboardInterrupt handling. - -- [ ] **Step 9: Run focused minor tests** - -Run: - -```bash -uv run pytest tests/tools/test_read_file.py tests/tools/cloud/test_base_stack.py tests/a2a/test_executor.py tests/pipeline/engine/test_completion_guard_state.py tests/pipeline/engine/test_step_executor.py tests/a2a/test_selling_console_script.py tests/repl_e2e/test_run_pipeline_scenarios.py -q -``` - -Expected: PASS. - -- [ ] **Step 10: Update translations** - -Run: - -```bash -make translate -``` - -Expected: command completes. Review `.po` changes and keep generated translation changes only if the project workflow updates them. - -- [ ] **Step 11: Commit Task 7** - -Run: - -```bash -git add src/iac_code/tools/base.py src/iac_code/tools/read_file.py src/iac_code/tools/cloud/base_stack.py src/iac_code/a2a/executor.py src/iac_code/pipeline/engine/user_input.py src/iac_code/pipeline/engine/completion_guard_state.py src/iac_code/pipeline/engine/step_executor.py src/iac_code/agent/agent_loop.py src/iac_code/ui/repl.py scripts/a2a/selling_console.py scripts/repl/e2e/run_pipeline_scenarios.py src/iac_code/i18n/locales tests -git commit -m "fix: close Windows i18n and compatibility gaps" -``` - -## Task 8: Documentation And Closure Summary - -**Files:** -- Modify: `/Users/ehzyo/open_repo/iac-code3/docs/batch/20260616-a2a-pipeline-cancel-handoff.md` -- Modify: `/Users/ehzyo/open_repo/iac-code3/docs/batch/20260622-pipeline-image-cherry-pick.md` -- Modify: `/Users/ehzyo/open_repo/iac-code3/docs/batch/20260623-selling-console-cherry-pick.md` -- Modify: `/Users/ehzyo/open_repo/iac-code3/docs/pipeline-image-manual-test-guide.zh-CN.md` -- Modify: `/Users/ehzyo/open_repo/iac-code3/scripts/README.md` -- Modify: `/Users/ehzyo/open_repo/iac-code3/scripts/repl/e2e/README.zh-CN.md` -- Create: `/Users/ehzyo/open_repo/iac-code3/scripts/repl/e2e/README.md` -- Create: `/Users/ehzyo/open_repo/iac-code3/docs/pipeline-schema-reference.md` -- Create: `/Users/ehzyo/open_repo/iac-code3/docs/review-fix-summary.md` - -- [ ] **Step 1: Update default-cwd docs** - -In `/Users/ehzyo/open_repo/iac-code3/docs/batch/20260616-a2a-pipeline-cancel-handoff.md`, describe: - -```markdown -If `--default-cwd` points inside the configured workspace root and the directory does not exist yet, the A2A executor may create it. Requests are rejected only when the resolved path escapes the allowed root, cannot be created, or cannot be used as a directory. -``` - -- [ ] **Step 2: Update A2A image docs** - -In `/Users/ehzyo/open_repo/iac-code3/docs/batch/20260622-pipeline-image-cherry-pick.md` and debugger docs, add: - -```markdown -Image input accepts supported image MIME types only. Inline or local payloads are size-limited by the A2A part parser. `file://` inputs must resolve under the request cwd or another allowed read root; local URLs outside those roots are rejected. The A2A debugger sends image parts. The Selling Console web UI currently sends text input only. -``` - -- [ ] **Step 3: Fix stale paths and REPL E2E docs** - -In `/Users/ehzyo/open_repo/iac-code3/docs/pipeline-image-manual-test-guide.zh-CN.md`, replace `.worktrees/pipeline-image` with repository root wording. - -In `/Users/ehzyo/open_repo/iac-code3/scripts/repl/e2e/README.zh-CN.md`, replace `/tmp` with “system temporary directory”. - -Create `/Users/ehzyo/open_repo/iac-code3/scripts/repl/e2e/README.md` with: - -````markdown -# REPL Pipeline E2E Runner - -This runner is POSIX-only because it uses a real PTY through `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 `iac-code-repl-e2e-runs//--/`. - -The runner is for manual or smoke validation. It must not require real LLM or cloud credentials in automated unit tests. -```` - -- [ ] **Step 4: Add schema reference** - -Create `/Users/ehzyo/open_repo/iac-code3/docs/pipeline-schema-reference.md` documenting these fields with examples: - -- `completion_guards` -- `surface_overrides` -- `parameter_overrides` -- `a2a_artifacts` -- `exit_condition` -- `inject_tools` -- `ui_mode` -- `conclusion_schema` -- `interrupt_judge_failure` -- `hooks_file` -- `enabled_when` - -Use examples copied from `/Users/ehzyo/open_repo/iac-code3/src/iac_code/pipeline/selling/pipeline.yaml` where available. - -- [ ] **Step 5: Update script and dependency docs** - -In `/Users/ehzyo/open_repo/iac-code3/scripts/README.md`, add entries for: - -- `scripts/a2a/selling_console.py` -- `scripts/a2a/selling_console_web/` -- `scripts/repl/e2e/run_pipeline_scenarios.py` - -Mention `pexpect` as a POSIX-only dev dependency for the real PTY runner. Document the `conftest.py` tiktoken isolation fixture in the nearest test README or `scripts/README.md`. - -- [ ] **Step 6: Create closure summary** - -Create `/Users/ehzyo/open_repo/iac-code3/docs/review-fix-summary.md` with a table: - -```markdown -# Review Fix Summary - -| Review item | Resolution | Tests | Residual risk | -| --- | --- | --- | --- | -| Critical 1 A2A delivered-but-not-recoverable event | Fixed by durable event classifier and durable journal/snapshot gate. | `tests/a2a/test_pipeline_stream.py`, `tests/a2a/test_pipeline_journal.py` | None | -| Critical 2 Windows read_file path check | Fixed by cross-platform normalization. | `tests/tools/test_read_file.py` | None | -| Historical 1 sidecar two-file consistency | Accepted residual risk. Single-file writes are atomic, but `context.yaml` and `meta.yaml` are not linked by generation/checksum in this batch. | `tests/pipeline/engine/test_session.py` | Crash between the two sidecar writes can leave the pair out of sync. | -``` - -Fill every Critical, Major, Minor, and historical item from `/Users/ehzyo/open_repo/iac-code3/docs/review.md` with one row. - -- [ ] **Step 7: Commit Task 8** - -Run: - -```bash -git add -f docs/batch/20260616-a2a-pipeline-cancel-handoff.md docs/batch/20260622-pipeline-image-cherry-pick.md docs/batch/20260623-selling-console-cherry-pick.md docs/pipeline-image-manual-test-guide.zh-CN.md docs/pipeline-schema-reference.md docs/review-fix-summary.md scripts/README.md scripts/repl/e2e/README.md scripts/repl/e2e/README.zh-CN.md -git commit -m "docs: document review fix closure" -``` - -## Task 9: Full Verification And Review Loop - -**Files:** -- Modify only if verification or review finds issues. -- Review output: `/Users/ehzyo/open_repo/iac-code3/docs/review-codex.md`, `/Users/ehzyo/open_repo/iac-code3/docs/review.md` if another merge is needed. - -- [ ] **Step 1: Run full test suite** - -Run: - -```bash -make test -``` - -Expected: PASS. If a test fails, use `superpowers:systematic-debugging`, fix the root cause, and commit the fix. - -- [ ] **Step 2: Run lint** - -Run: - -```bash -make lint -``` - -Expected: PASS. Fix lint or type issues and commit. - -- [ ] **Step 3: Search for review regressions** - -Run: - -```bash -rg -n "require_durable_metadata=False|journal\\.append\\(|Path\\.replace\\(|shutil\\.move\\(|CLEANUP_PROMPT_METADATA_TYPE|检测到 pipeline rollback|\\[Image input\\]|set\\.update\\([^)]*precompleted_tools|allow_reuse_address|shlex\\.split\\(" src scripts tests docs -``` - -Expected: - -- Remaining `journal.append(` calls are either best-effort display events or pass `durable=True` through classifier/group behavior. -- No raw `Path.replace()` remains for review-scoped A2A snapshot paths. -- No raw `shutil.move()` remains in SessionStorage migration. -- `CLEANUP_PROMPT_METADATA_TYPE` is defined in one low-dependency module and imported elsewhere. -- No Chinese msgids remain in source for cleanup UI text. -- `[Image input]` is translated. -- `set.update(precompleted_tools)` is gone. -- Selling Console Windows socket behavior is explicit. -- REPL E2E `shlex.split()` is guarded. - -- [ ] **Step 4: Request code review** - -Use `superpowers:requesting-code-review` and dispatch review agents focused on: - -- A2A durability and handoff recovery. -- cleanup ledger state preservation and corruption behavior. -- pipeline runner persistence boundaries. -- Windows/i18n/docs/minor closure. - -Each reviewer must read code, not only docs, and must review only branch changes from `02f0a57b` onward. - -- [ ] **Step 5: Merge review findings** - -If reviewers produce findings, merge them into `/Users/ehzyo/open_repo/iac-code3/docs/review.md`, preserving severity and source. Then repeat the repair loop from the relevant task above. - -- [ ] **Step 6: Final completion audit** - -Before marking the goal complete, verify: - -- Every row in `/Users/ehzyo/open_repo/iac-code3/docs/review.md` has a corresponding row in `/Users/ehzyo/open_repo/iac-code3/docs/review-fix-summary.md`. -- `make test` passed after the last code change. -- `make lint` passed after the last code change. -- Latest review round has no actionable findings. - -- [ ] **Step 7: Commit final review artifacts** - -Run: - -```bash -git add -f docs/review.md docs/review-fix-summary.md docs/review-codex.md -git commit -m "docs: record final review closure" -``` - -## Self-Review Notes - -- Spec coverage: the tasks cover state I/O, A2A durable events, active mismatch, cancel handoff atomicity, cleanup source of truth, cleanup ledger merge/mapping/corruption behavior, pipeline sidecar persistence, `/resume` damaged metadata, Windows compatibility, i18n, docs, closure summary, and final review loop. -- Historical hardening coverage: sidecar two-file consistency is explicitly retained as accepted residual risk; journal fsync, snapshot replace retry, SessionStorage save, SessionStorage move, Windows signal fallback, image privacy, and JSONL append serialization are covered by Tasks 1, 2, 3, 7, and 8. -- Placeholder scan: this plan avoids deferred-work markers, generic “handle edge cases” instructions, and abbreviated code. -- Type consistency: new helper names are `atomic_write_text`, `atomic_write_json`, `append_jsonl_locked`, `append_many`, `PipelineStatePersistenceError`, and `CLEANUP_PROMPT_METADATA_TYPE`. diff --git a/docs/superpowers/specs/2026-06-16-pipeline-image-support-design.md b/docs/superpowers/specs/2026-06-16-pipeline-image-support-design.md deleted file mode 100644 index 4331ce7d..00000000 --- a/docs/superpowers/specs/2026-06-16-pipeline-image-support-design.md +++ /dev/null @@ -1,198 +0,0 @@ -# Pipeline Image Support Design - -## Goal - -Pipeline mode should support the same image input capability that normal chat mode supports. Images must work across REPL input, A2A input, running-pipeline interrupts, user-question resumes, candidate selection resumes, and process/session recovery. - -This design intentionally scopes multimodal support to image input because normal mode currently supports image blocks, not arbitrary audio or binary payloads. - -## Confirmed Decisions - -- Support image input everywhere pipeline accepts user input. -- Allow pure-image input with no text. -- Reuse normal-mode image behavior wherever possible. -- Keep early model capability checks. If the current provider/model does not support images, fail or warn before starting work. -- Store image bytes inline as base64 in `ImageBlock.data`, matching normal-mode message storage. -- Resize/downsample A2A images using the same image processing path used by REPL images. -- Do not carry pipeline images into normal-mode handoff after pipeline completion. Handoff remains text summary only. -- Let interrupt judge see images. It may summarize image-derived information in `reason` or `rollback_context`. -- The final target step or candidate must still receive the original image blocks, not only the judge summary. - -## Architecture - -Add a small internal input wrapper, named `PipelineUserInput`, for all pipeline user input boundaries. - -```python -@dataclass(frozen=True) -class PipelineUserInput: - content: str | list[ContentBlock] - display_text: str - has_images: bool -``` - -`content` is the source of truth passed into `AgentLoop`. It can be a plain string or a structured list of `TextBlock` and `ImageBlock` values. `display_text` is used for UI rendering, A2A status events, logs, sidecar text fields, and interrupt prompt text. `has_images` lets callers treat pure-image input as non-empty. - -This wrapper should not replace `Message` and should not change provider APIs. It only prevents REPL, A2A, pipeline runner, sidecar, and interrupt code from each inventing its own `str | list[ContentBlock]` handling. - -## REPL Input Flow - -Pipeline mode should stop dropping `PromptInputResult.pasted_contents`. - -When REPL receives a pipeline input: - -1. If the input is already a plain string, create `PipelineUserInput(content=text, display_text=text, has_images=False)`. -2. If the input is a `PromptInputResult`, call the existing `process_user_input(text, pasted_contents=...)`. -3. If any resulting block is `ImageBlock`, keep the structured block list as `content`. -4. If there are no image blocks, keep the plain text as `content`. -5. Compute `display_text` from the user-visible prompt text. For pure-image input, use a safe placeholder such as `[Image input]`. - -The current pipeline warning saying images are ignored should be removed or inverted into tests that prove images are forwarded. - -The existing image attach path already performs capability gating through `is_model_multimodal(...)`. Pipeline should reuse that behavior and should not accept pasted images that normal mode would reject. - -## A2A Input Flow - -A2A currently converts image-like parts into a text manifest. Pipeline image support needs a new conversion path that returns internal content blocks. - -The converter should preserve existing text handling: - -- Text parts become `TextBlock`. -- JSON data parts with `application/json` continue to serialize to compact text. -- Raw text and text file URLs continue to become text. - -For supported image media types: - -- `raw` image bytes decode directly from the part. -- `data` image parts read base64 bytes from fields such as `bytes` or `base64`. -- `file://` image URL parts read bytes from a safe workspace-local path, preserving existing workspace and symlink escape checks. -- Image bytes are passed through the shared resize/downsample helper. -- The resized bytes are base64 encoded and emitted as `ImageBlock(media_type=..., data=...)`. - -Pipeline mode should treat image-only A2A requests as valid input. It should no longer fail them with a text-only message. If a request includes image input and the selected model is not multimodal, A2A should return a clear failed status rather than silently degrading to text. - -Audio and `application/octet-stream` are not included in this feature. They can keep existing manifest behavior or be rejected in the new pipeline multimodal converter, but they must not become image blocks. - -## Pipeline Runner Flow - -`PipelineRunner.run`, `resume`, `continue_from_sidecar`, `handle_user_interrupt`, and related A2A bridge calls should accept either `str` or `PipelineUserInput`. Internally they normalize to `PipelineUserInput`. - -The first step, resumed step, or injected target AgentLoop receives `PipelineUserInput.content`. - -Pipeline status events and observability continue to use text-safe fields derived from `display_text`. Input length metrics should use `len(display_text)` and may add a boolean such as `has_images` if useful. Telemetry content capture should continue recording text only, never base64 image data. - -`StepExecutor` already accepts `str | list[ContentBlock]`, so most step execution can remain unchanged. Helper functions that need text, such as completion guards or prompt context snapshots, should use text extracted from blocks or `display_text`. - -## Persistence and Recovery - -Pipeline step transcripts are the source of truth for recovering LLM context. They already store `Message.to_dict()` in JSONL and load through `Message.from_dict()`, so they can round-trip `ImageBlock` content. - -Use these persistence rules: - -- Pipeline transcripts store full structured `Message(content=list[ContentBlock])`, including image base64. -- Root visible session history stores only `display_text` for pipeline-visible user turns. -- Sidecar state machine `current_step_user_input` remains text-only `display_text`. It is a readable recovery hint, not the source of multimodal content. -- Session recovery should load repaired pipeline transcripts and preserve image blocks. -- Cache cleanup must not affect recovery because the transcript contains inline base64 image data. - -This keeps sidecar metadata small while preserving full image context where it matters. - -## Interrupt Judge - -`InterruptController.judge` should accept normalized pipeline input and send the judge model both the routing prompt text and any image blocks. - -For text plus image input, the judge request should contain: - -- A `TextBlock` with the current pipeline state, routing instructions, and user display text. -- The original `ImageBlock` values from the user input. - -For pure-image input, the text block should explicitly state that the user provided image input and that the judge should inspect it to determine routing. - -`InterruptVerdict` remains text-oriented. The judge can put image-derived details into `reason` or `rollback_context`, for example: "The uploaded diagram shows ECS behind SLB connected to RDS; rollback to architecture planning." - -Supplement behavior: - -- The target parent step or candidate AgentLoop receives the original `PipelineUserInput.content`. -- Judge image-derived text is used for routing only and is not injected as an extra replacement message. - -Hard interrupt behavior: - -- The rollback target receives both judge `rollback_context` and the original image input. -- This can be represented by prepending a `TextBlock` containing `rollback_context` to the original content blocks. -- The original image blocks must be preserved so the target step can independently inspect the image. - -If the judge fails or times out, existing interrupt failure policy still applies. The implementation must not silently drop image input. - -## Error Handling - -REPL: - -- If image paste is unsupported by the current model, reuse the normal-mode warning and do not attach the image. -- If resize/downsample fails, reuse normal-mode image error handling. -- If pure-image input is submitted after a successful attach, it is valid. - -A2A: - -- Invalid base64, unsafe file URL, non-file path, symlink escape, oversized image, and unsupported image media type return sanitized failure statuses. -- Error messages must not leak local file paths or base64 content. -- Model-not-multimodal with image input returns a clear failed status. - -Pipeline: - -- No pipeline branch should convert image input into a text-only manifest when the target path expects true image support. -- Empty text plus images is valid. Empty text with no images is still invalid where it is invalid today. - -## Testing Plan - -REPL tests: - -- Pipeline mode no longer warns that images are ignored. -- `PromptInputResult` with an image calls the pipeline handler with structured content. -- Pure-image input is accepted. -- Non-multimodal model still fails before attach, matching normal mode. - -A2A part conversion tests: - -- Raw image part becomes `ImageBlock`. -- Base64 data image part becomes `ImageBlock`. -- Safe file URL image part becomes `ImageBlock`. -- Resize/downsample is invoked for A2A images. -- Unsafe file URL, invalid base64, oversized content, and unsupported image media type fail safely. - -A2A executor tests: - -- Pipeline mode passes structured `PipelineUserInput` to the pipeline executor. -- Image-only requests are valid. -- Image input with non-multimodal model returns a clear failure. - -Pipeline runner tests: - -- `run`, `resume`, `continue_from_sidecar`, and `handle_user_interrupt` accept `PipelineUserInput`. -- Sidecar stores display text while transcripts store full image blocks. -- Restored transcripts preserve image blocks. -- Pure-image input is not treated as empty. - -Interrupt tests: - -- Judge provider request includes image blocks. -- Judge can produce image-derived `rollback_context`. -- Supplement injects original image input into the target AgentLoop. -- Hard interrupt restarts the target with both rollback context text and original image blocks. - -Regression tests: - -- Existing text-only pipeline behavior remains unchanged. -- Existing normal-mode image tests continue to pass. -- Existing A2A text, JSON, and text-file part handling remains unchanged. - -## Verification - -Relevant focused test commands: - -```bash -uv run pytest tests/ui/test_repl_pipeline_image_warning.py tests/utils/image/test_processor.py tests/providers/test_openai_image_blocks.py -uv run pytest tests/a2a/test_parts.py tests/a2a/test_executor.py tests/a2a/test_pipeline_executor.py -uv run pytest tests/pipeline/engine/test_pipeline_runner.py tests/pipeline/engine/test_pipeline_runner_interrupt.py tests/pipeline/engine/test_pipeline_runner_sidecar_path.py -uv run pytest tests/pipeline/engine/test_interrupt.py tests/pipeline/engine/test_transcript_storage.py -``` - -After implementation, run `make test` if feasible. The current baseline is known to fail six i18n tests because `src/iac_code/i18n/messages.pot` is missing in this worktree; that baseline issue is independent of this image-support design. diff --git a/docs/superpowers/specs/2026-06-16-pipeline-image-support-design.zh.md b/docs/superpowers/specs/2026-06-16-pipeline-image-support-design.zh.md deleted file mode 100644 index 28d72cc2..00000000 --- a/docs/superpowers/specs/2026-06-16-pipeline-image-support-design.zh.md +++ /dev/null @@ -1,198 +0,0 @@ -# Pipeline 图片支持设计 - -## 目标 - -Pipeline 模式应支持与普通聊天模式相同的图片输入能力。图片需要贯穿 REPL 输入、A2A 输入、运行中 pipeline 打断、用户问题恢复、候选方案选择恢复,以及进程/会话恢复。 - -本设计有意把多模态范围限定为图片输入,因为普通模式目前支持的是 image block,而不是任意音频或二进制载荷。 - -## 已确认决策 - -- Pipeline 接收用户输入的所有入口都支持图片。 -- 允许只有图片、没有文字的输入。 -- 尽可能复用普通模式的图片行为。 -- 保留早期模型能力检查。如果当前 provider/model 不支持图片,应在开始处理前失败或提示。 -- 图片 bytes 以内联 base64 存入 `ImageBlock.data`,与普通模式消息存储一致。 -- A2A 图片使用与 REPL 图片相同的处理路径进行 resize/downsample。 -- Pipeline 完成后,不把 pipeline 图片带入 normal mode handoff。Handoff 仍只使用文本总结。 -- Interrupt judge 可以看到图片,并可以把从图片中识别出的信息摘要写入 `reason` 或 `rollback_context`。 -- 最终目标 step 或 candidate 仍必须收到原始 image blocks,而不能只收到 judge 的摘要。 - -## 架构 - -新增一个很薄的内部输入包装类型,命名为 `PipelineUserInput`,作为所有 pipeline 用户输入边界的统一形态。 - -```python -@dataclass(frozen=True) -class PipelineUserInput: - content: str | list[ContentBlock] - display_text: str - has_images: bool -``` - -`content` 是传给 `AgentLoop` 的事实来源。它可以是纯字符串,也可以是由 `TextBlock` 和 `ImageBlock` 组成的结构化列表。`display_text` 用于 UI 渲染、A2A 状态事件、日志、sidecar 文本字段和 interrupt prompt 文本。`has_images` 用来让调用方把纯图片输入视为非空输入。 - -这个包装类型不替代 `Message`,也不修改 provider API。它只用于避免 REPL、A2A、pipeline runner、sidecar 和 interrupt 代码各自发明不同的 `str | list[ContentBlock]` 处理方式。 - -## REPL 输入流 - -Pipeline 模式不应再丢弃 `PromptInputResult.pasted_contents`。 - -当 REPL 收到 pipeline 输入时: - -1. 如果输入已经是纯字符串,创建 `PipelineUserInput(content=text, display_text=text, has_images=False)`。 -2. 如果输入是 `PromptInputResult`,调用现有的 `process_user_input(text, pasted_contents=...)`。 -3. 如果结果中有任何 `ImageBlock`,保留结构化 block list 作为 `content`。 -4. 如果没有 image block,保留纯文本作为 `content`。 -5. 根据用户可见的 prompt 文本计算 `display_text`。对于纯图片输入,使用安全占位符,例如 `[Image input]`。 - -当前提示图片会被忽略的 pipeline warning 应删除,或改成测试来证明图片会被转发。 - -现有图片 attach 路径已经通过 `is_model_multimodal(...)` 做能力门控。Pipeline 应复用该行为,并且不接受普通模式会拒绝的粘贴图片。 - -## A2A 输入流 - -A2A 目前会把类似图片的 parts 转成文本 manifest。Pipeline 图片支持需要新增一条转换路径,返回内部 content blocks。 - -转换器应保留现有文本处理: - -- Text parts 转成 `TextBlock`。 -- `application/json` 的 JSON data parts 继续序列化成紧凑文本。 -- Raw text 和文本 file URL 继续转成文本。 - -对于支持的图片媒体类型: - -- `raw` image bytes 直接从 part 中读取。 -- `data` image parts 从 `bytes` 或 `base64` 等字段读取 base64 bytes。 -- `file://` image URL parts 从安全的 workspace-local 路径读取 bytes,并保留现有 workspace 和 symlink escape 检查。 -- 图片 bytes 经过共享的 resize/downsample helper。 -- Resize 后的 bytes 进行 base64 编码,并输出为 `ImageBlock(media_type=..., data=...)`。 - -Pipeline 模式应把只有图片的 A2A 请求视为有效输入,不应再用 text-only message 让它失败。如果请求包含图片输入,而所选模型不是多模态模型,A2A 应返回清晰的失败状态,而不是静默降级成文本。 - -音频和 `application/octet-stream` 不包含在本功能范围内。它们可以保持现有 manifest 行为,或在新的 pipeline 多模态转换器中被拒绝,但不能变成 image blocks。 - -## Pipeline Runner 流程 - -`PipelineRunner.run`、`resume`、`continue_from_sidecar`、`handle_user_interrupt` 以及相关 A2A bridge 调用应接受 `str` 或 `PipelineUserInput`。内部统一 normalize 成 `PipelineUserInput`。 - -第一个 step、恢复的 step 或被注入的目标 AgentLoop 接收 `PipelineUserInput.content`。 - -Pipeline 状态事件和 observability 继续使用由 `display_text` 派生的文本安全字段。输入长度指标应使用 `len(display_text)`,如果有帮助,也可以增加 `has_images` 之类的布尔字段。Telemetry content capture 仍只记录文本,绝不记录 base64 图片数据。 - -`StepExecutor` 已经接受 `str | list[ContentBlock]`,所以大多数 step 执行逻辑可以保持不变。需要文本的辅助函数,例如 completion guards 或 prompt context snapshots,应使用从 blocks 中提取出的文本,或使用 `display_text`。 - -## 持久化与恢复 - -Pipeline step transcript 是恢复 LLM 上下文的事实来源。它们已经用 JSONL 存储 `Message.to_dict()`,并通过 `Message.from_dict()` 读取,因此可以 round-trip `ImageBlock` 内容。 - -持久化规则如下: - -- Pipeline transcripts 存储完整的结构化 `Message(content=list[ContentBlock])`,包括图片 base64。 -- Root visible session history 只为 pipeline-visible 用户 turn 存储 `display_text`。 -- Sidecar state machine 的 `current_step_user_input` 保持 text-only `display_text`。它是可读的恢复提示,不是多模态内容来源。 -- 会话恢复应加载修复后的 pipeline transcripts,并保留 image blocks。 -- Cache cleanup 不应影响恢复,因为 transcript 内联包含 base64 图片数据。 - -这样可以让 sidecar metadata 保持较小,同时在真正需要的地方保留完整图片上下文。 - -## Interrupt Judge - -`InterruptController.judge` 应接受 normalize 后的 pipeline 输入,并把路由 prompt 文本和所有 image blocks 都发送给 judge 模型。 - -对于文字加图片输入,judge 请求应包含: - -- 一个 `TextBlock`,包含当前 pipeline 状态、路由指令和用户 display text。 -- 用户输入中的原始 `ImageBlock` 值。 - -对于纯图片输入,text block 应明确说明用户提供了图片输入,并要求 judge 结合图片判断路由。 - -`InterruptVerdict` 仍是文本导向的。Judge 可以把从图片中识别出的细节写入 `reason` 或 `rollback_context`,例如:“上传的架构图显示 ECS 通过 SLB 连接 RDS;应回滚到 architecture planning。” - -Supplement 行为: - -- 目标 parent step 或 candidate AgentLoop 接收原始 `PipelineUserInput.content`。 -- Judge 从图片中得到的文本只用于路由,不作为额外替代消息注入。 - -Hard interrupt 行为: - -- 回滚目标同时接收 judge 的 `rollback_context` 和原始图片输入。 -- 可以通过在原始 content blocks 前面 prepend 一个包含 `rollback_context` 的 `TextBlock` 来表示。 -- 原始 image blocks 必须保留,让目标 step 能独立检查图片。 - -如果 judge 失败或超时,继续使用现有 interrupt failure policy。实现不能静默丢弃图片输入。 - -## 错误处理 - -REPL: - -- 如果当前模型不支持图片粘贴,复用普通模式 warning,并且不 attach 图片。 -- 如果 resize/downsample 失败,复用普通模式图片错误处理。 -- 如果图片成功 attach,纯图片输入也是有效输入。 - -A2A: - -- 无效 base64、不安全 file URL、非文件路径、symlink escape、超大图片和不支持的图片媒体类型,都返回清洗过的失败状态。 -- 错误消息不能泄漏本地文件路径或 base64 内容。 -- 图片输入遇到非多模态模型时,返回清晰失败状态。 - -Pipeline: - -- 当目标路径期望真实图片支持时,任何 pipeline 分支都不应把图片输入转换成 text-only manifest。 -- 空文本加图片是有效输入。没有文字也没有图片时,在今天无效的地方仍然无效。 - -## 测试计划 - -REPL 测试: - -- Pipeline 模式不再提示图片会被忽略。 -- 带图片的 `PromptInputResult` 会用结构化 content 调用 pipeline handler。 -- 纯图片输入可以被接受。 -- 非多模态模型仍在 attach 前失败,和普通模式一致。 - -A2A part 转换测试: - -- Raw image part 转成 `ImageBlock`。 -- Base64 data image part 转成 `ImageBlock`。 -- 安全的 file URL image part 转成 `ImageBlock`。 -- A2A 图片会调用 resize/downsample。 -- 不安全 file URL、无效 base64、超大内容和不支持的图片媒体类型会安全失败。 - -A2A executor 测试: - -- Pipeline 模式把结构化 `PipelineUserInput` 传给 pipeline executor。 -- 只有图片的请求是有效的。 -- 图片输入遇到非多模态模型时返回清晰失败。 - -Pipeline runner 测试: - -- `run`、`resume`、`continue_from_sidecar` 和 `handle_user_interrupt` 都接受 `PipelineUserInput`。 -- Sidecar 保存 display text,transcripts 保存完整 image blocks。 -- 恢复后的 transcripts 保留 image blocks。 -- 纯图片输入不会被当成空输入。 - -Interrupt 测试: - -- Judge provider request 包含 image blocks。 -- Judge 可以产生从图片中识别出的 `rollback_context`。 -- Supplement 会把原始图片输入注入目标 AgentLoop。 -- Hard interrupt 会用 rollback context 文本和原始 image blocks 重启目标。 - -回归测试: - -- 现有 text-only pipeline 行为保持不变。 -- 现有 normal-mode 图片测试继续通过。 -- 现有 A2A text、JSON 和 text-file part 处理保持不变。 - -## 验证 - -相关 focused 测试命令: - -```bash -uv run pytest tests/ui/test_repl_pipeline_image_warning.py tests/utils/image/test_processor.py tests/providers/test_openai_image_blocks.py -uv run pytest tests/a2a/test_parts.py tests/a2a/test_executor.py tests/a2a/test_pipeline_executor.py -uv run pytest tests/pipeline/engine/test_pipeline_runner.py tests/pipeline/engine/test_pipeline_runner_interrupt.py tests/pipeline/engine/test_pipeline_runner_sidecar_path.py -uv run pytest tests/pipeline/engine/test_interrupt.py tests/pipeline/engine/test_transcript_storage.py -``` - -实现后,如果可行,运行 `make test`。当前 baseline 已知会因为 `src/iac_code/i18n/messages.pot` 在此 worktree 中缺失而导致 6 个 i18n 测试失败;该 baseline 问题与本图片支持设计无关。 diff --git a/docs/superpowers/specs/2026-06-17-pipeline-rollback-cleanup-design.md b/docs/superpowers/specs/2026-06-17-pipeline-rollback-cleanup-design.md deleted file mode 100644 index 277c7600..00000000 --- a/docs/superpowers/specs/2026-06-17-pipeline-rollback-cleanup-design.md +++ /dev/null @@ -1,300 +0,0 @@ -# Pipeline Rollback Cleanup Design - -## Summary - -When the selling pipeline reaches step 5 (`deploying`), it may create an Alibaba Cloud ROS Stack before the step finishes. If the user then rolls back, cancels, or interrupts the pipeline before the `deployment` conclusion is committed, the stack can remain in the cloud without a reliable cleanup record. - -This design records stack resources as soon as they are observed, marks only step 5 rollback-related resources as cleanup-required, and starts cleanup after the pipeline hands off to normal chat. Cleanup is executed by the normal AgentLoop, not by a custom cleanup executor. The cleanup prompt is stored in the normal transcript but is not rendered as a visible user prompt in the REPL. - -## Goals - -- Prevent ROS Stack leakage caused by step 5 rollback in the selling pipeline. -- Persist resource observations and cleanup requirements so crashes do not lose cleanup state. -- Keep pipeline engine generic by moving selling/ROS-specific interpretation into step hooks. -- Use the normal AgentLoop for cleanup, so the user can cancel or continue naturally. -- Let REPL and A2A surfaces show cleanup status without rendering the synthetic cleanup prompt as user input. -- Support both `ros_stack DeleteStack` and `aliyun_api DeleteStack` plus `GetStack` polling, since the model may choose either path. -- Support resume and concurrent A2A sessions without cross-session cleanup state collisions. - -## Non-Goals - -- Do not block rollback while synchronously deleting resources. -- Do not introduce a custom cleanup executor that directly calls cloud tools. -- Do not build a general cloud CMDB. -- Do not clean resources from normal successful deployments. -- Do not clean all failed or canceled stacks by default; first scope is only step 5 rollback leakage. - -## Architecture - -The feature is split into four small parts: - -1. Resource observation: cloud stack creation emits a resource-observed notification as soon as the stack id is known. -2. Step hooks: the current step hook converts resource notifications into generic descriptors and later decides which observed resources need cleanup after rollback. -3. Cleanup prompt injection: after pipeline handoff to normal chat, pending cleanup resources trigger a synthetic normal AgentLoop turn. -4. Cleanup observer: while the normal AgentLoop runs, a per-session observer listens to tool events and updates cleanup status. - -The engine owns persistence and lifecycle wiring. The selling `deploying` hook owns ROS-specific interpretation. - -## Persistent Ledger - -The cleanup ledger is stored under the pipeline sidecar and written atomically. It is the source of truth for resume, retries, and A2A state. - -Example: - -```yaml -observed_resources: - - id: ros-stack/stack-xxx - source_pipeline: selling - source_step: deploying - source_attempt: att_0005 - observed_at: 1781630000.0 - resource: - provider: ros - type: stack - id: stack-xxx - name: demo-stack - region_id: cn-hangzhou - cleanup_required: false - -cleanup_resources: - - id: ros-stack/stack-xxx - source_observed_id: ros-stack/stack-xxx - reason: rollback_from_deploying - status: pending - cleanup_attempts: 0 - cleanup_run_id: cleanup-0001 - resource: - provider: ros - type: stack - id: stack-xxx - name: demo-stack - region_id: cn-hangzhou - accepted_cleanup_sequences: - - kind: terminal_tool - tool: ros_stack - delete_action: DeleteStack - success_status: DELETE_COMPLETE - failure_status: DELETE_FAILED - - kind: async_api_polling - delete_tool: aliyun_api - delete_action: DeleteStack - status_tool: aliyun_api - status_action: GetStack - success_status: DELETE_COMPLETE - failure_status: DELETE_FAILED -``` - -Statuses: - -- `pending`: cleanup is required but no matching cleanup call has started. -- `running`: a matching cleanup call or polling sequence is in progress. -- `succeeded`: cleanup reached a terminal success state such as `DELETE_COMPLETE`. -- `failed`: cleanup reached a terminal failure state or the tool returned an error. -- `skipped`: the user explicitly chose to keep the resource. - -## Resource Observation - -Resource observation must happen before final tool result handling. Waiting for `ToolResultEvent` is too late because step 5 can be interrupted or crash while the stack tool is still polling. - -For ROS stack creation: - -1. `CreateStack` returns `stack_id`. -2. The tool emits a `ResourceObservedEvent` containing action, stack id, stack name, region id, tool use id, step id, and attempt id. -3. The current step hook receives the notification. -4. The hook returns an `ObservedResource` descriptor. -5. The engine persists that descriptor to the ledger. -6. The stack tool continues normal polling. - -If the process crashes after the cloud API succeeds but before local persistence, the stack can still be missed. A later enhancement can reduce this window by forcing stack names or tags to include session and attempt identifiers. That enhancement is outside the first implementation scope. - -## Step Hook Responsibilities - -The selling `deploying` hook gets two new optional hook points: - -```python -def on_resource_observed(event, context) -> ObservedResource | None: - ... - -def on_rollback_cleanup_required(context, ledger, rollback) -> list[CleanupResource]: - ... -``` - -`on_resource_observed` turns ROS stack creation notifications into generic observed resource descriptors. - -`on_rollback_cleanup_required` runs when a rollback leaves `deploying`. It selects only the observed resources created by the relevant `deploying` attempt and returns cleanup descriptors. This keeps the pipeline engine from hardcoding ROS details. - -The hook may also provide: - -```python -def render_cleanup_prompt(resources) -> str: - ... -``` - -If absent, the engine uses a generic prompt built from cleanup descriptors. - -## Cleanup Prompt Injection - -When the pipeline transitions to normal chat, the normal chat runtime checks the ledger. If any cleanup resource has `pending`, `running`, or `failed` status and is not `skipped`, the runtime injects a synthetic cleanup turn into the normal AgentLoop. - -Important behavior: - -- The cleanup prompt is stored in the normal transcript. -- The REPL does not render the prompt as a visible user message. -- The REPL renders a separate status line, for example: `Detected 1 leaked rollback resource; starting cleanup.` -- A2A publishes cleanup state events and includes cleanup state in snapshots. -- The cleanup turn uses normal AgentLoop tool execution, permissions, cancellation, and transcript behavior. - -Prompt requirements: - -- Ask the model to clean only the listed resources. -- Allow `ros_stack DeleteStack`. -- Allow `aliyun_api DeleteStack` followed by `aliyun_api GetStack` polling. -- Require terminal confirmation such as `DELETE_COMPLETE`. -- Forbid creating, updating, or deleting resources outside the list. - -## Cleanup Observer - -CleanupObserver is a per-session, per-AgentLoop listener. It does not execute tools and does not call `GetStack`. It only observes normal AgentLoop events and updates the ledger. - -Startup conditions: - -- Immediately after injecting a cleanup prompt. -- On `--resume` when the session ledger contains pending/running/failed cleanup resources. -- On A2A task/context resume with pending/running/failed cleanup resources. -- Before the next normal user turn if cleanup remains unresolved. - -The observer is scoped by cwd, session id, task id, context id, pipeline run id, and cleanup run id where available. It must not subscribe to a global event stream without scope filtering. - -Event handling: - -- `ToolUseEndEvent`: if tool input matches a cleanup delete operation, mark the resource `running` and record `tool_use_id`. -- `StackProgressEvent`: update latest stack status/progress when it can be attributed to a cleanup resource. -- `ToolResultEvent` from `ros_stack`: parse the result. Mark `succeeded` only on `is_success=true` and `status=DELETE_COMPLETE`; mark `failed` on error or `DELETE_FAILED`. -- `ToolResultEvent` from `aliyun_api DeleteStack`: mark delete request observed, but not succeeded. -- `ToolResultEvent` from `aliyun_api GetStack`: parse stack status. Mark `succeeded` on `DELETE_COMPLETE`, `failed` on `DELETE_FAILED`, otherwise keep `running`. - -The observer matches resource semantics rather than hardcoding one tool. The descriptor says which operation sequences are accepted. - -## REPL UX - -The synthetic cleanup prompt is not printed as user text. Instead, REPL displays cleanup state: - -- `Detected N leaked rollback resources; starting cleanup.` -- `Cleanup running: ` -- `Cleanup completed: ` -- `Cleanup failed: ` - -Tool calls and stack progress still render normally. - -Resume behavior: - -- `--resume` reads the ledger and display replay. -- The user can see prior cleanup status messages. -- If cleanup remains unresolved, the next normal chat turn triggers or continues cleanup. - -## A2A UX - -A2A publishes cleanup-specific metadata events scoped to the current task/context: - -- `cleanup_resources_detected` -- `cleanup_started` -- `cleanup_progress` -- `cleanup_completed` -- `cleanup_failed` - -Snapshot cleanup shape: - -```json -{ - "cleanup": { - "status": "running", - "pendingCount": 1, - "runningCount": 1, - "failedCount": 0, - "succeededCount": 0, - "resources": [ - { - "id": "ros-stack/stack-xxx", - "provider": "ros", - "type": "stack", - "resourceId": "stack-xxx", - "regionId": "cn-hangzhou", - "status": "running", - "latestStackStatus": "DELETE_IN_PROGRESS" - } - ] - } -} -``` - -Each event includes task id, context id, pipeline run id, cleanup run id, and resource id so concurrent A2A sessions do not collide. - -## Cancellation and Retry - -Users can cancel the cleanup turn because cleanup runs through the normal AgentLoop. - -If canceled: - -- The ledger remains `pending` or `running`. -- The next resume or normal turn rechecks the ledger. -- Cleanup is prompted again unless the user explicitly skips it. - -If cleanup fails: - -- Status becomes `failed`. -- A later cleanup prompt can retry. -- A simple "continue" means retry cleanup. -- Only an explicit "keep these resources" or "skip cleanup" marks resources `skipped`. - -If another session already deleted the stack: - -- A missing stack or already-deleted stack should be treated as successful cleanup when the cleanup path can confidently identify the target. - -## Concurrency - -CleanupObserver is not global. It is attached to the AgentLoop and session being observed. - -Ledger updates include enough scope to avoid cross-session writes: - -- cwd -- session id -- task id and context id for A2A -- pipeline run id when available -- cleanup run id -- cleanup resource id - -If two sessions happen to try deleting the same cloud stack, each session only updates its own ledger. Cloud deletion is treated idempotently where possible. - -## Observability - -Add observability events for: - -- resource observed -- cleanup resource required -- cleanup prompt injected -- cleanup started -- cleanup progress -- cleanup succeeded -- cleanup failed -- cleanup skipped - -Attributes should include pipeline name, session id, source step, source attempt, cleanup run id, provider, resource type, resource id, region, status, and error category when available. - -## Tests - -Unit tests: - -- `CreateStack` returns stack id and triggers early resource observation before final tool result. -- `deploying` hook converts resource observations into observed descriptors. -- step 5 rollback converts only matching observed resources into cleanup resources. -- normal successful deployment does not mark cleanup required. -- cleanup prompt is injected when pending cleanup exists. -- cleanup prompt is persisted in transcript but hidden in REPL rendering. -- CleanupObserver marks `ros_stack` `DELETE_COMPLETE` as succeeded. -- CleanupObserver marks `ros_stack` error or `DELETE_FAILED` as failed. -- CleanupObserver handles `aliyun_api DeleteStack` plus `GetStack DELETE_IN_PROGRESS` plus `DELETE_COMPLETE`. -- `--resume` starts observer/injection from ledger state. -- A2A events include scope and snapshot cleanup state. -- concurrent observers do not update each other's ledgers. - -No tests may call real Alibaba Cloud APIs. diff --git a/docs/superpowers/specs/2026-06-18-selling-pipeline-console-design.md b/docs/superpowers/specs/2026-06-18-selling-pipeline-console-design.md deleted file mode 100644 index a4709d69..00000000 --- a/docs/superpowers/specs/2026-06-18-selling-pipeline-console-design.md +++ /dev/null @@ -1,171 +0,0 @@ -# Selling Pipeline Local Console Design - -## Purpose - -Build a local website under `scripts/` for the Alibaba Cloud selling pipeline. The site should present the full A2A pipeline interaction as a product console, matching the provided Alibaba Cloud console screenshots: top console navigation, a left AI workflow panel, a right "Your purchase plans" area, and a floating utility rail. - -The existing `scripts/a2a/debugger.py` remains the protocol reference. The new site reuses its A2A request, SSE, task identity, snapshot, pause, cancel, logging, and replay lessons, but changes the primary experience from a raw debugger to a selling pipeline workflow. - -## Goals - -- Add a new local entry point at `scripts/a2a/selling_console.py`. -- Add static web assets under `scripts/a2a/selling_console_web/`. -- Support the complete selling pipeline loop: - - Start from a deployment requirement prompt. - - Stream A2A pipeline events. - - Render requirement understanding, architecture planning, candidate evaluation, candidate selection, deployment, rollback, and handoff states. - - Handle `input_required` pauses for clarification, candidate choice, deployment confirmation, and permission-related waits. - - Continue normal chat after `pipeline_handoff_ready`. - - Cancel active tasks and fetch current pipeline state. -- Match the screenshots closely enough for product review: - - Alibaba Cloud-style top bar. - - Left workflow cards with green completion checks. - - Expanded "Plan selection" section with candidate radio cards. - - Bottom composer with "Deep thinking", attachment, and send controls. - - Right plan cards with orange monthly price, summary, and blue highlights. - - Desktop layout with a narrow left rail and right utility rail. -- Keep the tool local-only and unauthenticated, following the debugger model. - -## Non-Goals - -- Do not replace `scripts/a2a/debugger.py`. -- Do not introduce a frontend framework or package manager. -- Do not call real Alibaba Cloud APIs from the console itself. -- Do not add authentication, account switching, or real console navigation behavior. -- Do not rely on real LLM, real cloud credentials, or network calls in tests. - -## Architecture - -The new Python server follows the debugger's self-contained pattern: - -- `SellingConsoleConfig` - - host, port, default A2A server URL, default cwd, log directory, optional replay export. -- Protocol helpers - - Reuse or mirror the debugger semantics for: - - URL normalization. - - JSON fetch with A2A headers. - - `SendStreamingMessage`. - - `GetTask`. - - `CancelTask`. - - Pipeline state fetch. - - SSE line parsing. - - debug log append/load. -- HTTP routes - - `/` serves `index.html`. - - `/styles.css` and `/app.js` serve static assets safely from `selling_console_web`. - - `/api/health` proxies server health and agent card. - - `/api/message/stream` proxies A2A streaming responses. - - `/api/pipeline/state` proxies `iac-code/pipeline/state`. - - `/api/task/get` proxies `GetTask`. - - `/api/task/cancel` proxies `CancelTask`. - -The frontend owns the selling-specific rendering. It should not depend on hidden globals from `debugger.py`; instead, it receives defaults from a small JSON bootstrap script and uses browser-native JavaScript. - -## Frontend Layout - -The page uses three primary bands: - -- Top console bar - - Menu icon, Alibaba Cloud mark, workspace button, account resources dropdown, region dropdown, search box, docs/cost/ticket links, language and notification icons, user badge. - - These controls are visual affordances only. -- Main shell - - Left narrow assistant rail with the robot avatar. - - Left workflow panel, fixed around the screenshot proportions on desktop. - - Right content area titled "Your purchase plans". - - Right floating utility rail. -- Bottom composer inside the left panel - - Placeholder text for continuing or refining requirements. - - "Deep thinking" toggle-style button. - - Attachment icon and send icon button. - - Disclaimer text matching the screenshot tone. - -The layout must remain usable on narrow screens. Below desktop widths, the plan area stacks under the workflow panel and all text must fit without overlap. - -## Pipeline State Model - -The frontend reducer translates A2A events and snapshots into a UI model: - -- Session identity - - `contextId`, `pipelineTaskId`, `activeTaskId`, `lastSequence`, `status`, `normalHandoffReady`. -- Steps - - `intent_parsing` -> "Requirement understanding". - - `architecture_planning` -> "Architecture planning". - - `evaluate_candidates` and candidate sub-steps -> "Plan evaluation". - - `confirm_and_select` -> "Plan selection". - - `deploying` -> "Deployment". -- Candidate data - - Candidate name, zero-based index, summary, cost items, total monthly cost, diagram/artifact metadata, source raw event. - - Prefer `display.candidateDetails` and `candidate_detail` events. - - Fall back to `complete_step.conclusion.options` and candidate snapshot data. -- Pending input - - Question prompt, options, free-text allowance, related step/candidate coordinates. -- Permission state - - Tool name, safe summary, decision status, guidance text. -- Raw diagnostics - - Keep a collapsible debug drawer for requests, SSE events, and snapshots so protocol problems remain inspectable. - -The reducer must be tolerant of both camelCase and snake_case fields, because existing debugger code handles both. - -## Interaction Flow - -1. User enters a requirement in the composer and sends it. -2. The frontend posts `/api/message/stream` with `serverUrl`, `cwd`, current `contextId`, current stream task id, and prompt. -3. The stream parser reads SSE chunks incrementally, parses `data:` lines, appends diagnostics, and applies pipeline envelopes as they arrive. -4. If an `input_required` event or A2A input-required task status arrives, the stream reader cancels the browser reader and shows the pending question in the workflow panel. -5. When candidates are available, the console renders: - - radio cards in the left "Plan selection" section; - - larger plan cards in the right content area. -6. Choosing a candidate sets local selection state. Sending the selection emits a natural-language follow-up, such as `选择方案0` for candidate index 0. -7. Deployment confirmation and permission waits use the same pending-input path. The user response is sent as the next message in the same context. -8. When `pipeline_handoff_ready` switches to normal mode, clear the active pipeline task id and keep the context id so follow-up chat starts a new normal task. - -## Visual Details - -Colors and spacing should approximate the screenshots: - -- White page background with subtle blue/pink glow behind the left panel. -- Thin borders around workflow cards and plan cards. -- Alibaba Cloud orange for the logo and price. -- Bright green status checks. -- Blue highlight text for plan advantages. -- Rounded corners around cards, no nested decorative cards beyond the repeated workflow and plan cards. -- Compact but readable Chinese-first copy. - -Icons should be implemented as inline buttons using simple CSS or Unicode where no icon library exists. The implementation should avoid external CDNs. - -## Error Handling - -- Invalid server URL, missing cwd, and missing prompt show inline composer errors. -- Proxy failures show an alert row in the workflow panel and a diagnostic row in the debug drawer. -- Empty streams append a diagnostic event and leave the page interactive. -- Cancel failure displays an inline error without clearing the current state. -- Snapshot fetch failures do not destroy existing UI state. - -## Tests - -Add focused pytest coverage under `tests/a2a/`: - -- Script help exits successfully and describes the local selling console. -- Static index route serves HTML with default server URL and cwd. -- Static asset serving rejects path traversal. -- Defaults JSON is safe in script context. -- API payload builders preserve A2A v1 method names and cwd metadata. -- Existing debugger proxy behavior is not changed. -- Embedded or external JavaScript passes `node --check` when Node is installed. - -Manual/browser verification: - -- Start the local server. -- Open the page in the in-app browser. -- Verify desktop screenshot fit: top bar, workflow panel, plan cards, and utility rail are visible without overlap. -- Verify narrow viewport fit: content stacks and text remains readable. -- Exercise a mocked or replayed candidate-selection state if a real A2A server is unavailable. - -## Acceptance Criteria - -- `scripts/a2a/selling_console.py` can run locally with `uv run python scripts/a2a/selling_console.py`. -- The page visually matches the provided screenshots in structure, spacing, colors, and key copy. -- The console can start a selling pipeline request against a local A2A server. -- The console can render pipeline progress, candidate options, right-side plan cards, pending input, deployment status, and normal handoff. -- Tests for the new script and assets pass. -- Relevant existing A2A debugger tests still pass. diff --git a/docs/superpowers/specs/2026-06-19-repl-pipeline-e2e-design.md b/docs/superpowers/specs/2026-06-19-repl-pipeline-e2e-design.md deleted file mode 100644 index a7d49e7a..00000000 --- a/docs/superpowers/specs/2026-06-19-repl-pipeline-e2e-design.md +++ /dev/null @@ -1,313 +0,0 @@ -# REPL Pipeline E2E Design - -## Summary - -Add a real end-to-end regression runner for the selling pipeline through the -interactive REPL terminal. This complements the existing A2A recovery runner: -A2A exercises the public JSON-RPC/SSE entrypoint, while this runner exercises -the user-facing PTY entrypoint, including Rich live rendering, raw keyboard -input, candidate selection UI, interrupt handling, resume replay, and handoff -from pipeline mode to normal chat. - -The runner lives under `scripts/`, not `tests/`, because it intentionally uses -real provider configuration, real model calls, and real pipeline tools. It is a -manual/regression script like `scripts/a2a/e2e/run_recovery_scenarios.py`, not a -default pytest target. - -## Goals - -- Regress pipeline behavior through the real interactive terminal path. -- Use the real user's configured `~/.iac-code` by default. -- Use real LLM/provider calls and, for cloud scenarios, real cloud credentials. -- Drive the CLI as a black box through a pseudo-terminal. -- Capture transcripts and structured run summaries for debugging. -- Start with a small scenario set, then grow toward the A2A e2e matrix where it - makes sense for the REPL surface. - -## Non-Goals - -- Do not add pytest tests that call real providers or real cloud APIs. -- Do not fake the LLM, provider, pipeline tools, or cloud APIs in this runner. -- Do not replace the A2A e2e runner. -- Do not duplicate low-level unit or component coverage already present under - `tests/ui`, `tests/commands`, `tests/pipeline`, and `tests/a2a`. -- Do not make ordinary `make test` depend on this script. - -## Location - -Create: - -```text -scripts/repl/e2e/run_pipeline_scenarios.py -scripts/repl/e2e/README.zh-CN.md -``` - -Optionally create `scripts/repl/e2e/common.py` if the runner grows beyond one -file. Keep the first implementation in one script unless shared helpers become -meaningful. - -Update `scripts/README.md` to list the new REPL pipeline e2e runner. - -## Execution Model - -The runner starts `iac-code` in pipeline mode inside a PTY: - -```bash -IAC_CODE_MODE=pipeline uv run iac-code --permission-mode bypass_permissions -``` - -The process is driven like a user: - -- send text prompts with Enter; -- wait for visible terminal markers; -- send arrow keys or Enter for candidate selection; -- send Esc and follow-up text for hard interrupts; -- terminate or kill the process for recovery scenarios; -- restart with `--resume` or `--continue` when a scenario needs resume. - -Use `pexpect` for the PTY harness. If it is not already available in the -project environment, add it via the project's dependency management in -`pyproject.toml` and `uv.lock`. A real PTY library is worth the small dependency -because this runner must send keys, wait on terminal text, handle timeouts, and -produce useful transcripts. - -## Real Configuration - -By default, the runner uses the current user's real environment and -configuration: - -- do not override `HOME`; -- do not set `IAC_CODE_CONFIG_DIR`; -- do not create an isolated fake `.iac-code`; -- allow existing `~/.iac-code/settings.yml`, credentials, model, and provider - state to be used. - -The runner may accept optional one-run overrides: - -```text ---provider dashscope ---model qwen3.6-plus ---api-base https://... -``` - -These become environment variables for the child process only. They must not -write `settings.yml`. - -If the parent shell already has `IAC_CODE_CONFIG_DIR` set, the runner should -print it in the run summary so it is obvious that the real default -`~/.iac-code` was not used. It should not silently mutate it. - -## Safety Gate - -Require an explicit opt-in for scenarios that can call real providers and cloud -tools: - -```text ---allow-real-cloud -``` - -This mirrors the A2A e2e runner. The name is intentionally the same because the -pipeline scenarios may deploy or clean real cloud resources. - -Read-only or deterministic future scenarios may relax this gate, but the first -pipeline scenarios should require it. - -## Run Artifacts - -Unless `--run-root` or `--run-dir` is provided, write artifacts under: - -```text -/tmp/iac-code-repl-e2e-runs//--/ -``` - -Key files: - -- `summary.json`: scenario result, checks, timings, command, cwd, session id if - detected, and notes. -- `transcript.raw.log`: exact PTY output after redaction. -- `transcript.normalized.log`: ANSI-stripped and whitespace-normalized output - used for checks. -- `events.jsonl`: runner actions and observations such as `spawn`, `send`, - `expect`, `timeout`, `kill`, `restart`, and `check`. -- `child.env.json`: redacted child environment subset relevant to provider, - mode, config dir, and model. - -All logs must redact API keys, bearer tokens, secrets, passwords, and credential -values. Reuse the A2A redaction approach where practical. - -## Initial Scenarios - -### `scenario1` - -Purpose: baseline full selling pipeline through the REPL. - -Flow: - -1. Start `iac-code` with `IAC_CODE_MODE=pipeline`. -2. Send `选择一个已有vpc,创建一个vswitch`. -3. Wait for candidate selection UI or equivalent candidate selection marker. -4. Select a candidate using the terminal interaction path. -5. Wait for pipeline completion and normal-chat handoff. -6. Send a normal follow-up prompt such as `你刚才创建了什么`. -7. Verify the terminal produces a non-empty answer and the run does not crash. -8. Exit cleanly. - -Checks: - -- pipeline started; -- candidate selection became visible; -- candidate selection input was accepted through the terminal; -- pipeline completed; -- normal-chat handoff happened; -- follow-up answer produced text. - -### `ask-waiting` - -Purpose: cover interactive clarification through the REPL. - -Flow: - -1. Start pipeline mode. -2. Send `我有个产品要上线`. -3. Wait for a clarification question. -4. Send a clarifying answer such as - `我要创建云网络资源;本次只选择已有 VPC 创建一个 VSwitch,不部署 ECS、EIP、SLB 或 Nginx。` -5. Continue until candidate selection or completion. - -Checks: - -- ask-user-question UI became visible; -- typed clarification was accepted; -- pipeline continued beyond the ask step. - -### `selection-waiting-resume` - -Purpose: cover persisted candidate selection and startup replay through the -interactive REPL. - -Flow: - -1. Start pipeline mode. -2. Send the baseline VSwitch prompt. -3. Wait until candidate selection is visible and waiting. -4. Kill the process. -5. Restart with `--continue` or `--resume `. -6. Verify the candidate selection display replays. -7. Select a candidate. -8. Wait for pipeline completion. - -Checks: - -- waiting candidate selection was persisted; -- restart restored the same session; -- terminal candidate selection UI was usable after restore; -- pipeline completed after restored selection. - -### `rollback-step3` - -Purpose: cover REPL hard-interrupt rollback through Esc input. - -Flow: - -1. Start pipeline mode. -2. Send the baseline VSwitch prompt. -3. Wait until the pipeline reaches candidate generation or candidate evaluation. -4. Send Esc. -5. Type a rollback instruction, for example - `回退到 intent_parsing,选择一个已有vpc,创建一个安全组`. -6. Wait for rollback feedback and subsequent progress. - -Checks: - -- Esc interrupt was observed by the REPL path; -- rollback instruction was accepted; -- pipeline reported rollback/progress after the interrupt. - -This scenario can be timing-sensitive. If it proves flaky, keep it out of the -default scenario list and document it as a targeted regression. - -## Future Scenario Parity - -After the initial runner is stable, add REPL counterparts for high-value A2A -scenarios: - -- `cancel-step1` through `cancel-step5`; -- `rollback-step1` through `rollback-step5`; -- `rollback-step5-cleanup`; -- `rollback-step5-cleanup-recovery`. - -Do not force one-to-one parity where the A2A scenario validates protocol-only -behavior. REPL scenarios should focus on terminal-specific pipeline behavior. - -## CLI - -Proposed options: - -```text ---scenario repeatable, defaults to scenario1 ---allow-real-cloud required for real pipeline scenarios ---cwd workspace for the child process, defaults to run dir workspace ---run-root artifact root ---run-dir exact artifact dir, only with one scenario ---python defaults to "uv run python" ---provider child env override only ---model child env override only ---api-base child env override only ---timeout default command/expect timeout ---stream-timeout long wait for provider/pipeline progress ---leave-running keep child process alive after failure for debugging -``` - -The spawned command should prefer module execution for consistency with A2A: - -```text - -m iac_code.cli.main --permission-mode bypass_permissions -``` - -Set `IAC_CODE_MODE=pipeline` in the child environment. - -## Checking Strategy - -Terminal output is not a stable API. Checks should avoid brittle full-screen -snapshots. Prefer coarse markers: - -- known step names or translated pipeline labels; -- candidate selection prompt/options; -- completion or handoff markers; -- non-empty assistant response after handoff; -- persisted session id or resume command if available. - -Each expect operation should append an event to `events.jsonl` with the pattern, -timeout, pass/fail, and a short transcript tail. This makes failures diagnosable -without reading the whole raw log. - -## Failure Handling - -On failure: - -1. append failure details to `events.jsonl`; -2. write `summary.json` with `passed=false`; -3. terminate the child process unless `--leave-running` is set; -4. print the run dir and failed checks. - -On timeout, include the last transcript tail in both the terminal summary and -`summary.json`, after redaction. - -## Documentation - -`scripts/repl/e2e/README.zh-CN.md` should explain: - -- this runner uses real `~/.iac-code`; -- it can call real providers and real cloud APIs; -- required safety flag; -- recommended first command; -- scenario descriptions; -- artifact locations; -- how to inspect transcripts after failure. - -## Test Coverage For The Runner - -Do not run real REPL scenarios in pytest. If we add tests for the runner itself, -place them under `tests/` and only test pure helpers such as argument parsing, -redaction, run directory creation, transcript normalization, and summary -formatting. diff --git a/docs/superpowers/specs/2026-06-23-review-fixes-design.md b/docs/superpowers/specs/2026-06-23-review-fixes-design.md deleted file mode 100644 index 2be1f0bb..00000000 --- a/docs/superpowers/specs/2026-06-23-review-fixes-design.md +++ /dev/null @@ -1,362 +0,0 @@ -# Review Fixes Design - -## Context - -This design covers all actionable items in `docs/review.md`, including the section labeled `原本就有的问题`. The work will be implemented as one coordinated design split into multiple implementation batches. - -The review spans A2A pipeline recovery, A2A event durability, cleanup ledger correctness, pipeline sidecar persistence, Windows compatibility, i18n, documentation, and minor cleanup items. The implementation must stay within existing module boundaries and avoid unrelated refactors. - -## Goals - -- Fix every current `Critical`, `Major`, and `Minor` item in `docs/review.md`. -- Include the historical hardening items listed in `原本就有的问题`, with any accepted residual risks explicitly named and justified. -- Preserve normal mode behavior unless the review item explicitly targets normal mode performance or shared infrastructure. -- Avoid real LLM, real cloud, or real network requirements in tests. -- Produce a closure document summarizing each review item as fixed or accepted residual risk. -- For the historical hardening section, every item must have either an implementation strategy or an explicitly named accepted residual risk. - -## Non-Goals - -- Do not merge `context.yaml` and `meta.yaml` into a single sidecar state file. -- Do not add generation/checksum consistency between sidecar `context.yaml` and `meta.yaml` in this round. -- Do not introduce a cleanup append-only journal. -- Do not add cross-process file locking for `cleanup.yaml`; the supported model is one process writing a ledger. -- Do not make the real PTY REPL E2E runner support Windows. It will be documented and guarded as POSIX-only. -- Do not rewrite the entire pipeline documentation system beyond the schema/reference and concrete gaps called out in review. - -## Accepted Residual Risk - -`PipelineSession` will continue to store sidecar state in two files: `context.yaml` and `meta.yaml`. This round will improve single-file atomicity through a shared state I/O helper, but it will not add a shared generation/checksum or merge the files. A crash between the two file writes can still leave the pair out of sync. This is accepted for this batch and must be recorded in `docs/review-fix-summary.md`. - -## Architecture - -The design uses a shared state I/O layer and then applies fixes in dependent batches: - -1. State I/O foundation. -2. A2A recovery and handoff semantics. -3. Cleanup ledger correctness. -4. Pipeline runner persistence behavior. -5. Windows, i18n, documentation, and minor closure. - -The core reliability rule is: - -- Events or files that affect recovery semantics must be persisted before they are treated as successful. -- Display-only streaming data can remain best-effort to preserve interactive latency. - -## Batch 1: State I/O Foundation - -### Atomic State Writes - -Add a low-dependency helper for state-file writes. It should support: - -- Write to a temporary file in the destination directory. -- Flush and fsync file contents for state files that affect recovery semantics. -- Atomically replace the target. -- Best-effort fsync of the parent directory. -- Short retry around replace failures, especially for Windows file-lock behavior. -- Clear exceptions when writes cannot be made durable. - -For the review-scoped state files in this design, file fsync is required by default. Optional/no-fsync behavior is allowed only for explicitly best-effort display data, not for `cleanup.yaml`, A2A snapshots, sidecar YAML, or session full-file saves. - -The helper will be used only for review-scoped paths: - -- `pipeline/cleanup.yaml` -- A2A pipeline snapshot files -- `PipelineSession` sidecar `context.yaml` and `meta.yaml` -- `SessionStorage.save()` and related replace/move paths called out by review - -This is not a whole-repository migration. - -### A2A Journal Durability - -Extend the A2A journal API with: - -- `append(event, durable: bool = False)` -- `append_many(events, durable: bool = False)` - -Durable appends must write, flush, and fsync. Best-effort appends can flush without fsync. - -`append_many()` is required for event groups such as cancel plus handoff. The group must be atomic from the journal replay and snapshot-reducer perspective. Implement this either by writing one JSONL group record that expands to multiple events when read, or by using group metadata plus a commit marker and ignoring incomplete groups during replay. A plain loop that writes multiple independent JSONL event lines is not sufficient because a crash between lines can leave a half group. Durable groups should fsync once after the complete group representation is written. If a durable group fails, callers must not treat any event in the group as successfully persisted or delivered. - -For single durable A2A events, publishing succeeds only after at least one durable metadata sink succeeds for that event. The journal append is the preferred durable sink; a snapshot save can be a fallback only if it includes the event's recovery-relevant state. If neither durable journal append nor recovery-relevant snapshot save succeeds, the event must not be delivered as successful state. - -Add a centralized durable-event classifier in the A2A publisher or an adjacent low-dependency module. The effective durable flag should be `caller_requested_durable or is_recovery_semantic_event(envelope)`. Do not rely on scattered call sites remembering to pass `require_durable_metadata=True` for every recovery-relevant event. Tests should cover representative translated events and manual events so `pipeline_started`, step state, candidate state, input, terminal, handoff, cleanup, and artifact metadata events all route through durable persistence even when the caller does not pass an explicit flag. - -If `append_many()` uses one JSONL group record, `read_all()` and `read_all_repairing_tail()` must expand committed group records into normal events before sorting/reducing, and the high-water sequence logic must see the expanded events. If it uses group metadata plus a commit marker, replay must ignore uncommitted groups completely. Either form must preserve the in-group event order for events with adjacent sequences. - -### Session Storage Save - -Keep `SessionStorage.save()` as a full-file save API. It still receives the complete message list and writes the complete JSONL session file. - -Change the write path from truncate-and-rewrite to atomic replace. Cleanup prompt preservation becomes explicit and opt-in. Normal mode saves should not read the old session file just to scan for cleanup prompts. Preservation should be enabled only from flows that may rewrite or compact context while needing to retain hidden cleanup prompts. - -Add a focused append helper for SessionStorage JSONL appends. It should serialize appends inside the current process and use the best available platform mechanism to avoid interleaving when another process also appends to the same file. On POSIX this can use an advisory lock around the append section; on Windows it should use the corresponding standard-library file locking primitive where feasible. The append operation should write each JSONL record as one encoded line while holding the lock. If a platform lock cannot be acquired, append should fail loudly rather than silently risk interleaving. - -This append helper is scoped to SessionStorage JSONL files. It does not introduce cross-process locking for `cleanup.yaml`. - -The legacy-to-directory session migration path must also stop relying on raw `shutil.move()` for the review-scoped Windows target-exists case. If the directory-format `session.jsonl` already exists, migration should leave it authoritative. If the legacy file is the source of truth, move or replace it through the shared retry helper or an equivalent explicit Windows-safe path, with clear failure rather than silent partial migration. - -### ToolContext Compatibility - -Restore the historical positional argument contract: - -1. `cwd` -2. `event_queue` -3. `tool_use_id` - -Move newly added fields after `tool_use_id`, and prefer keyword usage for new fields. Add a regression test asserting `ToolContext("/tmp", None, "toolu-1").tool_use_id == "toolu-1"`. - -### Windows Path Safety - -Replace `read_file.py`'s local `_path_is_under()` behavior with the existing cross-platform path normalization from `tools/path_safety.py`, or make the local helper equivalent. It must handle Windows drive-letter case and separator normalization. - -## Batch 2: A2A Recovery And Handoff Semantics - -### Narrow Durable Event Model - -Only events that change recovery semantics are durable. Display-only events remain best-effort. - -Durable examples: - -- pipeline started -- step start, completion, and failure -- candidate selection, completion, and failure -- `input_required` -- terminal task or pipeline states -- `pipeline_canceled` -- `pipeline_handoff_ready` -- cleanup state transitions -- artifact metadata creation - -Best-effort examples: - -- `text_delta` -- candidate detail display -- diagram display -- ordinary tool result display -- permission display - -If an ordinary tool result implies recovery state, the recovery-relevant information should be extracted into a separate durable state event. The raw tool result can remain best-effort. - -### Active Sidecar Task Mismatch - -When a `running` or `waiting_input` sidecar exists and the incoming A2A request does not match the owner task, the executor must not clear the sidecar and must not start a new pipeline. - -Return a JSON-RPC error, using the existing invalid-params style, with machine-readable error data: - -- `recoverableTaskId` -- `contextId` -- `sidecarStatus` - -The message should tell the client to resume the returned task id. Debugger and selling console should surface the returned task id clearly. - -### Cancel Handoff Event Group - -Persist `pipeline_canceled` and `pipeline_handoff_ready` as a durable event group through `journal.append_many()`. Snapshot reduction should happen after the complete group has been appended. - -If the group cannot be persisted, the executor must not publish successful cancel or handoff state. Tests should cover failure injection between the two events and assert no durable state contains only `pipeline_canceled` without the corresponding handoff. - -### Cleanup Handoff Source Of Truth - -`pipeline/cleanup.yaml` is the only authoritative cleanup source for service-side cleanup prompt recovery. - -The public A2A snapshot exists for Web UI recovery and display. It may contain public resource summaries and cleanup status, but it must not be used to reconstruct service-side cleanup prompt semantics. - -Normal and cancel handoff should derive cleanup handoff data from the private ledger. If the ledger is missing or unreadable, the system should expose `cleanup state unavailable` rather than guessing from public snapshot resources. - -## Batch 3: Cleanup Ledger Correctness - -### In-Process Serialization - -Serialize read-modify-write operations for the same cleanup ledger path within one process. Cross-process locking is out of scope. - -### State Merge Rules - -`mark_cleanup_required()` must merge with existing resources rather than replacing them blindly. - -Rules: - -- `completed` and `skipped` remain terminal. -- `started`, `in_progress`, and `failed` must not regress to `pending`. -- Execution fields such as `cleanup_tool_use_id`, `cleanup_action`, `progress_status`, `progress_percentage`, and `last_error` are preserved unless a deliberate status transition replaces them. -- Declarative fields such as reason, source step, and metadata can be refreshed. - -### Persistent Tool-Use Mapping - -When `CleanupObserver` observes DeleteStack or GetStack tool use, persist a minimal tool-use mapping in the ledger: - -- `tool_use_id` -- provider -- resource type -- resource id -- region -- action -- sanitized input summary needed for matching - -Tool results first use the in-memory mapping. If it is missing, they load the persisted mapping from the ledger. If no mapping exists, they log and record a cleanup history warning instead of guessing. - -### Corrupt Ledger Behavior - -Ledger parse or structure failure is fail-closed: - -- Do not silently no-op. -- Do not overwrite the corrupt ledger. -- Do not create an empty replacement ledger. -- Do not inject automatic cleanup prompts from partial state. - -REPL, A2A, and Web UI surfaces should expose cleanup state unavailable and instruct users to inspect the session file and cloud resources manually. - -### Cloud Resource Observation Window - -No additional recovery subsystem will be added for the small window between successful cloud API creation and observed-resource ledger persistence. The existing synchronous `ResourceObservedEvent` path, ledger write retry, and explicit failure surfacing are considered sufficient for this round. - -## Batch 4: Pipeline Runner Persistence - -Pipeline runner checkpoints that affect recovery semantics must not fail silently. - -Use the shared retry helper for critical sidecar saves. The runner sidecar save helpers should return success/failure or raise a dedicated persistence error; they must not swallow persistence failures and let callers continue as if the checkpoint succeeded. If retries fail, pipeline execution stops before advancing to later steps or issuing further cloud operations. The user-facing error should clearly state that pipeline state persistence failed. - -For checkpoints that currently happen after an in-memory transition, such as `state_machine.advance()`, rollback, interrupt rollback, or terminal handoff metadata, the implementation must establish an explicit persistence boundary. Prefer persisting the state that will be resumed before emitting success/terminal events or starting downstream work. If the in-memory transition must happen first because the resumable snapshot is derived from mutated state, then a persistence failure must immediately stop the pipeline, surface the persistence error, and avoid yielding success, handoff, or downstream execution events that imply the transition is durable. - -### REPL Resume Damaged Sidecar Fallback - -The `/resume` flow must handle damaged or racing pipeline sidecar metadata gracefully. After `has_resumable_status()` detects a candidate sidecar, `_confirm_pipeline_resume()` should catch `FileNotFoundError`, `OSError`, `UnicodeDecodeError`, and `yaml.YAMLError`, and should validate that parsed metadata is a mapping before reading fields. On failure, the REPL should show a clear warning and continue in normal chat mode or offer the existing discard path; it must not crash during session swap. - -Tests should inject sidecar save failures and assert: - -- downstream steps or cloud tools are not executed after persistent failure -- REPL/A2A surfaces receive a clear error -- retry success allows normal continuation -- damaged `/resume` sidecar metadata does not crash the REPL and leaves the user in a coherent normal/discard fallback - -The sidecar two-file consistency residual risk remains accepted and documented. - -## Batch 5: Windows, I18n, Docs, And Minor Closure - -### Windows - -Runtime paths should be cross-platform. Real PTY REPL E2E scripts can be POSIX-only. - -Runtime fixes include: - -- path normalization for `read_file` -- atomic replace retry and explicit failure surfacing -- state-file write helper coverage for review-scoped files -- explicit Windows handling for REPL signal registration: guard unsupported `loop.add_signal_handler` behavior, keep the fallback path intentional, and add a focused test for the fallback when feasible; if the test cannot run on the current platform, document that verification limit in the closure summary -- image store privacy behavior: apply restrictive permissions through the best available platform API; on Windows, use an equivalent ACL-based approach where practical, and only document a residual limitation if the standard library cannot enforce one -- Selling Console socket reuse behavior: avoid unsafe Windows `SO_REUSEADDR` semantics by disabling address reuse on Windows or using the platform's exclusive-address option when available - -Script and documentation fixes include: - -- guard real PTY E2E runner on Windows with a clear error -- mark `pexpect` usage or runner docs as POSIX-only -- replace hard-coded `/tmp` docs with system temporary directory wording -- document Windows limitations for real PTY E2E -- fix or guard REPL E2E `shlex.split()` usage so Windows paths are not parsed with POSIX-only assumptions -- document that cleanup ledger temporary-file names using a dot prefix are cosmetic and not relied on for security or Windows hiding behavior -- make Selling Console shutdown handle `KeyboardInterrupt` cleanly enough that worker threads do not keep the process alive unexpectedly - -### I18n - -Source msgids for user-visible UI, CLI, and A2A text should be English. Chinese text belongs in the Chinese catalog. - -Fix: - -- A2A image input errors -- `[Image input]` -- cleanup status, badge, and user-visible cleanup prompt text - -Use `_("... {name} ...").format(name=...)`, not f-strings around `_()`. Update translation files through the project workflow. - -### Documentation - -Fix every documentation gap called out in `docs/review.md`: - -- `--default-cwd` behavior and directory-creation side effect -- A2A image MIME, size, and `file://` safety limits -- Selling Console text-only capability versus A2A debugger image support -- stale pipeline-image worktree path -- REPL E2E English/POSIX/system-temp documentation -- VSwitch template commit documentation -- `pexpect` dev dependency mention -- scripts README entries -- `conftest.py` tiktoken isolation fixture documentation -- correction for batch docs that call real ROS template files "test template files" - -Add a formal pipeline schema reference covering at least: - -- `completion_guards` -- `surface_overrides` -- `parameter_overrides` -- `a2a_artifacts` -- `exit_condition` -- `inject_tools` -- `ui_mode` -- `conclusion_schema` -- `interrupt_judge_failure` -- `hooks_file` -- `enabled_when` - -### Minor Code Cleanup - -Fix all remaining Minor items: - -- centralize `CLEANUP_PROMPT_METADATA_TYPE` -- broaden legacy cleanup prompt detection or migrate old cleanup prompts to metadata so localized or older hidden prompts do not appear in session titles -- guard empty `stack_id` before emitting `ResourceObservedEvent` -- add useful completion guard logging -- centralize cleanup event names or enum-like constants -- display `deliveryTaskId` and `deliveryContextId` where relevant -- reduce unnecessary normal-mode cleanup scans -- remove duplicate `_pipeline_mode` assignment -- warn when deploying cleanup hook lacks `from_attempt_id` -- replace `set.update(dict)` with explicit key update -- add focused docstrings for cleanup state model and selling console script - -### Closure Summary - -Create `docs/review-fix-summary.md` after implementation. It should map each review item to: - -- fixed -- test coverage -- accepted residual risk - -The sidecar two-file consistency issue must be recorded as an accepted residual risk. Any platform limitation discovered for image-store privacy enforcement must also be recorded if it cannot be fully fixed with the standard library. - -## Testing Strategy - -Add focused pytest coverage for each high-risk fix: - -- durable A2A event failure behavior -- durable A2A event classification for translated and manual recovery events -- `append_many()` cancel handoff atomicity -- active sidecar mismatch error data -- cleanup ledger state merge -- cleanup persisted tool-use mapping -- corrupt cleanup ledger fail-closed behavior -- session atomic save and preservation on/off -- session JSONL append locking/serialization -- legacy session migration Windows-safe replace/move behavior -- `ToolContext` positional compatibility -- Windows path normalization -- Windows signal fallback and script guard behavior where feasible -- pipeline persistence retry/failure stop -- `/resume` damaged sidecar metadata fallback -- i18n string extraction patterns where feasible - -Final verification should include: - -- `make test` -- `make lint` - -Real LLM, real cloud, and real PTY E2E are not required for this repair batch. - -## Implementation Order - -1. State I/O foundation. -2. A2A recovery and handoff semantics. -3. Cleanup ledger correctness. -4. Pipeline runner persistence. -5. Windows, i18n, docs, and minor closure. - -Each batch should leave the test suite in a runnable state before moving to the next. diff --git a/docs/superpowers/specs/2026-06-24-review2-fixes-design.md b/docs/superpowers/specs/2026-06-24-review2-fixes-design.md deleted file mode 100644 index 830bf2e0..00000000 --- a/docs/superpowers/specs/2026-06-24-review2-fixes-design.md +++ /dev/null @@ -1,234 +0,0 @@ -# Review2 Fixes Design - -Date: 2026-06-24 - -## Goal - -Close every remaining issue listed in `docs/review2.md` without widening the feature scope. The work is split into two implementation batches: - -1. Reliability and user-visible correctness. -2. Maintainability and platform edge cases. - -The final implementation should update code, tests, and review documentation so `docs/review2.md` no longer contains unresolved findings, and should produce a dedicated `docs/review2-fix-summary.md` closure document. - -## Decisions - -- `CleanupLedger` corruption handling uses a continue-with-warning policy. - - Existing corrupt `cleanup.yaml` files must never be overwritten. - - Pipeline execution should continue. - - The runner must still make cleanup tracking unavailability visible through warning/event/observability paths. -- `AgentLoop.stamp_last_turn_elapsed()` must preserve cleanup prompts when it performs a full session save. -- All remaining minor findings in `docs/review2.md` should be fixed rather than left as accepted risks. -- The implementation should use small targeted changes and avoid broad refactors. - -## Batch 1: Reliability And User-Visible Correctness - -### Cleanup Ledger Corruption - -Current issue: `CleanupLedger.record_observed()` and `CleanupLedger.mark_cleanup_required()` return silently when `_load_for_write()` detects a corrupt ledger. This keeps the corrupt file intact, but the caller cannot tell that cleanup tracking failed. - -Design: - -- Keep fail-closed write behavior: do not overwrite unreadable or invalid ledger content. -- Change key write methods so callers can distinguish successful writes, no-op input, and unavailable ledger state. -- Have `PipelineRunner` handle unavailable ledger state without failing the pipeline. -- Emit both of these signals when cleanup tracking is unavailable: - - `logger.warning` with the ledger path, step id, attempted operation, and load error when available. - - A non-terminal pipeline-visible warning event. If no suitable warning event type exists, add a narrowly scoped warning event type rather than overloading `STEP_FAILED` or `PIPELINE_ERROR`. The event must not change the pipeline terminal status. -- Warning event data must include a stable reason such as `cleanup_tracking_unavailable`, the ledger path, step id, operation name, and resource id/count when available. -- If a new warning event type is added, wire it through every user-visible pipeline event path that handles `PipelineEventType` values: - - REPL rendering should show a non-terminal warning without marking the step or pipeline failed. - - A2A `PipelineEventTranslator` should publish a warning envelope that Web UI clients can display. - - A2A durable classification should explicitly decide the warning's recovery semantics. If the warning is needed after reconnect, classify it as recovery-semantic; otherwise document it as display-only. - - Display replay and snapshot reducers should either record/display the warning or explicitly ignore it with tests proving no terminal state changes. - -Tests: - -- Corrupt ledger is not overwritten. -- Pipeline continues after a cleanup tracking unavailable condition. -- The logger warning is emitted. -- The non-terminal pipeline-visible warning event is emitted and does not make the pipeline fail. -- REPL and A2A translation paths surface or explicitly handle the warning event without treating it as terminal failure. - -### Session Elapsed Stamp Preservation - -Current issue: `stamp_last_turn_elapsed()` rewrites the full session file without `preserve_cleanup_prompts=True`. If the session file contains a cleanup prompt that is missing from the in-memory context, the elapsed stamp write can remove it. - -Design: - -- Call `SessionStorage.save(..., preserve_cleanup_prompts=True)` from `stamp_last_turn_elapsed()`. -- Keep the rest of the elapsed-stamp behavior unchanged. - -Tests: - -- Arrange a session where disk contains a cleanup prompt and memory messages do not. -- Run `stamp_last_turn_elapsed()`. -- Verify the assistant elapsed value is persisted and the cleanup prompt remains in the session. - -### A2A Handoff Cleanup Documentation - -Current issue: `docs/review-fix-summary.md` says the A2A handoff cleanup residual risk is "none", but the accepted design is more specific. - -Design: - -- Document that private cleanup ledger is the source of truth for server-side cleanup prompt semantics. -- Document that public A2A snapshot exists only for Web UI recovery. -- Document that if the private ledger is missing or unreadable, normal mode does not reconstruct a cleanup prompt from public snapshot or journal evidence; it exposes cleanup state unavailable instead. - -### Selling Console Windows Socket Evidence - -Current issue: the code has a Windows branch for `allow_reuse_address`, but tests do not directly prove that branch. - -Design: - -- Add a direct test that monkeypatches the platform to Windows and verifies the created server class disables address reuse. -- Keep the documented behavior and implementation unchanged unless the test reveals an implementation problem. - -## Batch 2: Maintainability And Platform Edge Cases - -### Cleanup Event Constants - -Current issue: cleanup event constants exist but several production consumers still use hard-coded protocol strings. - -Design: - -- Replace production hard-coded cleanup event strings with constants from `iac_code.pipeline.constants`. -- Keep test literals where they assert protocol wire values. -- Ensure protocol values do not change. - -### Cleanup Module Documentation - -Current issue: `cleanup.py` lacks enough class and method documentation for the cleanup state model. - -Design: - -- Add concise docstrings for the core data classes and public ledger/observer methods. -- Explain state model, fail-closed behavior, and write semantics. -- Avoid long narrative comments or unrelated documentation churn. - -### JSON-RPC Error Data Passthrough - -Current issue: JSON-RPC error data passthrough monkey-patch logic is split across modules and one patch runs at import time. - -Design: - -- Introduce a small shared helper as the single installation path for passthrough behavior. -- Reuse it from both A2A app and pipeline executor code. -- Move installation to explicit A2A app/dispatcher startup paths. `pipeline_executor.py` must not install the monkey-patch merely by being imported. -- Preserve current behavior and idempotency. -- Avoid changing external JSON-RPC response shape except where existing passthrough behavior already does so. - -Tests: - -- Importing `iac_code.a2a.pipeline_executor` alone does not patch JSON-RPC response helpers. -- Explicit installation keeps recoverable error `data` passthrough behavior and remains idempotent. - -### Path Lock Registry Growth - -Current issue: `_PATH_LOCKS` and `_LEDGER_LOCKS` grow monotonically with path count in long-running processes. - -Design: - -- Replace the unbounded lock dictionaries with a reusable lock registry that cannot evict or replace a lock while it may still be held. -- Prefer weak-reference or reference-counted registry semantics over time/size-only eviction. A simple size cap is not acceptable if it can create a second live lock for the same path. -- Preserve per-path in-process serialization. -- Keep implementation simple and deterministic enough to test. - -Tests: - -- Same path returns the same lock while retained. -- A lock that is currently held remains the unique lock for that path. -- Stale locks can be released from the registry after no callers retain them. - -### macOS Path Case Handling - -Current issue: `_path_is_under()` case-normalizes Windows paths only. macOS default filesystems are often case-insensitive. - -Design: - -- Add a helper for case-insensitive path comparison when the underlying platform/path behavior requires it. -- Windows always uses case-insensitive comparison. -- macOS should use a conservative case-sensitivity probe for the relevant root path rather than assuming all macOS volumes are case-insensitive. -- Preserve behavior on case-sensitive POSIX filesystems. - -### Legacy Session Cross-Filesystem Migration - -Current issue: migration changed from `shutil.move()` to `safe_replace()`. `os.replace()` can fail across filesystems with `EXDEV`. - -Design: - -- Extend the shared replace/migration path to support cross-device fallback. -- On `EXDEV`, copy to the target, make the target durable/private, and unlink the legacy source only after the copy succeeds. -- Preserve Windows retry behavior for replace-related permission failures. - -Tests: - -- Simulate `EXDEV` and verify fallback copies content and removes the source. -- Verify normal replace path still works. - -### Recovery Semantic Event Simplification - -Current issue: `is_recovery_semantic_event()` contains an unreachable-looking final branch after earlier status checks. - -Design: - -- Simplify the predicate while preserving durable classification behavior. -- Add or update tests for the event/status/scope combinations that matter. - -### Unused Cleanup Payload Parameter - -Current issue: `_cleanup_payload_from_private_ledger_or_unavailable()` accepts `public_snapshot` but does not use it. - -Design: - -- Remove the unused parameter. -- Update call sites and tests. -- Keep the explicit design that public snapshot is not used to reconstruct server-side cleanup prompt semantics. - -## Documentation Updates - -Update `docs/review-fix-summary.md`: - -- Correct A2A handoff cleanup residual risk wording. -- Correct Selling Console Windows socket test evidence after adding the direct test. -- Correct any status text affected by the implementation. - -Update `docs/review2.md`: - -- Fix the stale "4 Major" count after the removed `ToolContext` item. -- Mark each finding as fixed or moved into the implementation summary. -- Keep source attribution only where useful for traceability. - -Create `docs/review2-fix-summary.md`: - -- Use a structure similar to `docs/review-fix-summary.md`. -- Map every `docs/review2.md` finding to its final handling status. -- For each item, include the implementation summary, test evidence, and residual risk. -- Explicitly note any user-approved design decisions, especially A2A public snapshot being Web UI recovery-only and cleanup ledger corruption continuing the pipeline with visible warning. -- Keep this as the authoritative closure document for the review2 fix batch. - -## Verification Plan - -Run focused tests first: - -- `tests/pipeline/engine/test_cleanup.py` -- `tests/pipeline/engine/test_pipeline_runner_cleanup.py` -- `tests/services/test_session_storage.py` -- `tests/a2a/test_selling_console_script.py` -- `tests/utils/test_state_io.py` -- `tests/tools/test_read_file.py` -- A2A tests covering JSON-RPC passthrough and cleanup payload behavior -- Documentation checks for `docs/review2.md`, `docs/review-fix-summary.md`, and `docs/review2-fix-summary.md` - -Then run: - -- `make test` - -If the full suite cannot run in the available environment, record the reason and list the focused tests that did run. - -## Non-Goals - -- Do not change A2A public snapshot into a server-side cleanup prompt source. -- Do not fail the pipeline solely because cleanup ledger tracking is unavailable. -- Do not redesign session storage beyond the specific preservation and migration fixes. -- Do not introduce platform-specific dependencies for Windows ACL behavior. diff --git a/templates/1-create-securitygroup-in-vpc.yml b/templates/1-create-securitygroup-in-vpc.yml deleted file mode 100644 index 98c9b3b2..00000000 --- a/templates/1-create-securitygroup-in-vpc.yml +++ /dev/null @@ -1,32 +0,0 @@ -ROSTemplateFormatVersion: '2015-09-01' -Metadata: - ALIYUN::ROS::Interface: - ParameterGroups: - - Parameters: - - VpcId - Label: - default: 网络配置 -Parameters: - VpcId: - Type: String - Label: - en: VPC - zh-cn: 专有网络 - AssociationProperty: ALIYUN::ECS::VPC::VPCId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} -Resources: - SecurityGroup: - Type: ALIYUN::ECS::SecurityGroup - Properties: - VpcId: !Ref VpcId - SecurityGroupName: app-security-group - Description: Application security group - SecurityGroupType: normal -Outputs: - SecurityGroupId: - Description: The ID of the created security group - Value: !GetAtt SecurityGroup.SecurityGroupId - Label: - en: Security Group ID - zh-cn: 安全组 ID diff --git a/templates/1-create-vswitch-in-existing-vpc.yml b/templates/1-create-vswitch-in-existing-vpc.yml deleted file mode 100644 index 5e6d7fb2..00000000 --- a/templates/1-create-vswitch-in-existing-vpc.yml +++ /dev/null @@ -1,56 +0,0 @@ -ROSTemplateFormatVersion: '2015-09-01' -Description: 在已有VPC下创建VSwitch -Metadata: - ALIYUN::ROS::Interface: - ParameterGroups: - - Parameters: - - VpcId - - ZoneId - - VSwitchCidrBlock - Label: - default: 网络配置 -Parameters: - VpcId: - Type: String - Label: - en: VPC - zh-cn: 专有网络 - AssociationProperty: ALIYUN::ECS::VPC::VPCId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} - ZoneId: - Type: String - Label: - en: Zone ID - zh-cn: 可用区 - AssociationProperty: ALIYUN::ECS::ZoneId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} - VSwitchCidrBlock: - Type: String - Default: 10.0.1.0/24 - Label: - en: VSwitch CIDR Block - zh-cn: 交换机网段 - Description: - en: The CIDR block of the VSwitch. Must be a subnet of the VPC CIDR. - zh-cn: 交换机的网段,必须是VPC网段的子网。 -Resources: - VSwitch: - Type: ALIYUN::ECS::VSwitch - Properties: - VpcId: !Ref VpcId - ZoneId: !Ref ZoneId - CidrBlock: !Ref VSwitchCidrBlock - VSwitchName: app-vswitch -Outputs: - VSwitchId: - Label: - en: VSwitch ID - zh-cn: 交换机ID - Value: !GetAtt VSwitch.VSwitchId - VSwitchCidrBlock: - Label: - en: VSwitch CIDR Block - zh-cn: 交换机网段 - Value: !GetAtt VSwitch.CidrBlock diff --git a/templates/1-security-group-in-existing-vpc.yml b/templates/1-security-group-in-existing-vpc.yml deleted file mode 100644 index 9d15d30f..00000000 --- a/templates/1-security-group-in-existing-vpc.yml +++ /dev/null @@ -1,48 +0,0 @@ -ROSTemplateFormatVersion: '2015-09-01' -Description: 在已有 VPC 中创建安全组 -Metadata: - ALIYUN::ROS::Interface: - ParameterGroups: - - Parameters: - - VpcId - Label: - default: 网络配置 -Parameters: - VpcId: - Type: String - Label: - en: VPC ID - zh-cn: 专有网络 - AssociationProperty: ALIYUN::ECS::VPC::VPCId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} -Resources: - SecurityGroup: - Type: ALIYUN::ECS::SecurityGroup - Properties: - VpcId: !Ref VpcId - SecurityGroupName: app-security-group - SecurityGroupIngress: - - IpProtocol: tcp - PortRange: 80/80 - SourceCidrIp: 0.0.0.0/0 - Priority: 1 - Description: HTTP - - IpProtocol: tcp - PortRange: 443/443 - SourceCidrIp: 0.0.0.0/0 - Priority: 1 - Description: HTTPS - SecurityGroupEgress: - - IpProtocol: all - PortRange: '-1/-1' - DestCidrIp: 0.0.0.0/0 - Priority: 1 - Description: Allow all outbound -Outputs: - SecurityGroupId: - Description: 创建的安全组 ID - Value: !Ref SecurityGroup - Label: - en: Security Group ID - zh-cn: 安全组 ID diff --git a/templates/1-single-vswitch.yml b/templates/1-single-vswitch.yml deleted file mode 100644 index a9e4bfcc..00000000 --- a/templates/1-single-vswitch.yml +++ /dev/null @@ -1,54 +0,0 @@ -ROSTemplateFormatVersion: '2015-09-01' -Metadata: - ALIYUN::ROS::Interface: - ParameterGroups: - - Parameters: - - VpcId - - ZoneId - - CidrBlock - Label: - default: 网络配置 -Parameters: - VpcId: - Type: String - Label: - en: VPC - zh-cn: 专有网络 - AssociationProperty: ALIYUN::ECS::VPC::VPCId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} - ZoneId: - Type: String - Label: - en: Zone ID - zh-cn: 可用区 - AssociationProperty: ALIYUN::ECS::ZoneId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} - CidrBlock: - Type: String - Label: - en: CIDR Block - zh-cn: 交换机网段 - Description: 交换机的 CIDR 网段,必须属于所属 VPC 的 CIDR 范围 -Resources: - VSwitch: - Type: ALIYUN::ECS::VSwitch - Properties: - VpcId: !Ref VpcId - ZoneId: !Ref ZoneId - CidrBlock: !Ref CidrBlock - VSwitchName: app-vswitch -Outputs: - VSwitchId: - Description: 创建的交换机 ID - Value: !GetAtt VSwitch.VSwitchId - Label: - en: VSwitch ID - zh-cn: 交换机 ID - CidrBlock: - Description: 交换机的 CIDR 网段 - Value: !GetAtt VSwitch.CidrBlock - Label: - en: CIDR Block - zh-cn: 交换机网段 diff --git a/templates/1-vswitch-in-existing-vpc.yml b/templates/1-vswitch-in-existing-vpc.yml deleted file mode 100644 index b6641df5..00000000 --- a/templates/1-vswitch-in-existing-vpc.yml +++ /dev/null @@ -1,54 +0,0 @@ -ROSTemplateFormatVersion: '2015-09-01' -Description: 在已有 VPC 中创建一个新的 VSwitch -Metadata: - ALIYUN::ROS::Interface: - ParameterGroups: - - Parameters: - - VpcId - - ZoneId - - CidrBlock - Label: - default: 网络配置 -Parameters: - VpcId: - Type: String - Description: 已有 VPC 的 ID - Label: - en: VPC ID - zh-cn: 专有网络 ID - AssociationProperty: ALIYUN::ECS::VPC::VPCId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} - ZoneId: - Type: String - Description: VSwitch 所在的可用区 - Label: - en: Zone ID - zh-cn: 可用区 - AssociationProperty: ALIYUN::ECS::ZoneId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} - CidrBlock: - Type: String - Description: VSwitch 的 CIDR 网段,如 172.16.0.0/24 - Label: - en: CIDR Block - zh-cn: 子网网段 - AssociationProperty: ALIYUN::VPC::VSwitch::CidrBlock - AssociationPropertyMetadata: - VpcId: ${VpcId} -Resources: - VSwitch: - Type: ALIYUN::ECS::VSwitch - Properties: - VpcId: !Ref VpcId - ZoneId: !Ref ZoneId - CidrBlock: !Ref CidrBlock - VSwitchName: vswitch-new -Outputs: - VSwitchId: - Description: 创建的 VSwitch ID - Value: !Ref VSwitch - Label: - en: VSwitch ID - zh-cn: 交换机 ID diff --git a/templates/1-vswitch-under-existing-vpc.yml b/templates/1-vswitch-under-existing-vpc.yml deleted file mode 100644 index 87eb566f..00000000 --- a/templates/1-vswitch-under-existing-vpc.yml +++ /dev/null @@ -1,48 +0,0 @@ -ROSTemplateFormatVersion: '2015-09-01' -Description: 在已有 VPC 下新建一个 VSwitch -Metadata: - ALIYUN::ROS::Interface: - ParameterGroups: - - Parameters: - - VpcId - - ZoneId - Label: - default: 网络配置 -Parameters: - VpcId: - Type: String - Label: - en: VPC ID - zh-cn: 专有网络 - AssociationProperty: ALIYUN::ECS::VPC::VPCId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} - ZoneId: - Type: String - Label: - en: Zone ID - zh-cn: 可用区 - AssociationProperty: ALIYUN::ECS::ZoneId - AssociationPropertyMetadata: - RegionId: ${ALIYUN::Region} -Resources: - VSwitch: - Type: ALIYUN::ECS::VSwitch - Properties: - VpcId: !Ref VpcId - ZoneId: !Ref ZoneId - CidrBlock: 192.168.0.0/24 - VSwitchName: app-vswitch -Outputs: - VSwitchId: - Description: 新建交换机 ID - Value: !GetAtt VSwitch.VSwitchId - Label: - en: VSwitch ID - zh-cn: 交换机 ID - CidrBlock: - Description: 交换机网段 - Value: !GetAtt VSwitch.CidrBlock - Label: - en: CIDR Block - zh-cn: 交换机网段 From 83da92b36d85af53464f737b4af3fa3c90df3fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Wed, 24 Jun 2026 13:31:36 +0800 Subject: [PATCH 55/59] fix: translate pipeline i18n messages --- .../i18n/locales/de/LC_MESSAGES/messages.po | 572 +++++++++++++----- .../i18n/locales/es/LC_MESSAGES/messages.po | 558 ++++++++++++----- .../i18n/locales/fr/LC_MESSAGES/messages.po | 568 ++++++++++++----- .../i18n/locales/ja/LC_MESSAGES/messages.po | 475 +++++++++++---- .../i18n/locales/pt/LC_MESSAGES/messages.po | 557 ++++++++++++----- .../i18n/locales/zh/LC_MESSAGES/messages.po | 437 +++++++++---- 6 files changed, 2368 insertions(+), 799 deletions(-) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index a82a4226..213ff6e5 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -48,6 +48,14 @@ msgstr "Unsicherer Artefaktdateiname" msgid "Unknown error" msgstr "Unbekannter Fehler" +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "" +"Cleanup state unavailable. Inspect the session file and cloud resources " +"manually." +msgstr "" +"Bereinigungsstatus nicht verfügbar. Prüfen Sie die Sitzungsdatei und " +"Cloud-Ressourcen manuell." + #: src/iac_code/a2a/executor.py msgid "" "Rollback cleanup deferred prompt state is unavailable. Please repair it " @@ -81,6 +89,11 @@ msgstr "Die Aufgabe läuft bereits." msgid "Task canceled." msgstr "Aufgabe abgebrochen." +#: src/iac_code/a2a/executor.py +#, fuzzy, python-brace-format +msgid "Current model {model} does not support image input." +msgstr "Modell {model} unterstützt keinen Thinking-Effort." + #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." msgstr "Ein temporärer Fehler ist aufgetreten. Bitte erneut versuchen." @@ -91,6 +104,11 @@ msgstr "" "Authentifizierung erforderlich. Anmeldedaten konfigurieren und erneut " "versuchen." +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "Pipeline already running. Resume task {task_id}." +msgstr "Pipeline läuft bereits. Setzen Sie Aufgabe {task_id} fort." + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "A2A-Pipeline-Zustand nicht gefunden" @@ -524,6 +542,22 @@ msgstr "KI-gestütztes Tool zur Orchestrierung von Infrastruktur" msgid "Use iac-code as an A2A client." msgstr "iac-code als A2A-Client verwenden." +#: src/iac_code/cli/main.py +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"A2A-Client-Abhängigkeiten fehlen. Installieren mit: pip install 'iac-" +"code[a2a]'" + +#: src/iac_code/cli/main.py +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"A2A-Server-Abhängigkeiten fehlen. Installieren mit: pip install 'iac-" +"code[a2a]'" + #: src/iac_code/cli/main.py msgid "Install Git for Windows via the npmmirror mirror (Windows only)." msgstr "Git for Windows über den npmmirror-Spiegel installieren (nur Windows)." @@ -536,14 +570,6 @@ msgstr "iac-code auf die neueste Version aktualisieren." msgid "YAML config file containing A2A client options" msgstr "YAML-Konfigurationsdatei mit A2A-Client-Optionen" -#: src/iac_code/cli/main.py -msgid "" -"A2A client dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "" -"A2A-Client-Abhängigkeiten fehlen. Installieren mit: pip install 'iac-" -"code[a2a]'" - #: src/iac_code/cli/main.py msgid "LLM model to use" msgstr "Zu verwendendes LLM-Modell" @@ -682,14 +708,6 @@ msgstr "" "Legt A2A-Thinking-Signaltypen offen; fuer mehrere Werte wiederholen. " "Werte: raw-thinking, tool-trace." -#: src/iac_code/cli/main.py -msgid "" -"A2A server dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "" -"A2A-Server-Abhängigkeiten fehlen. Installieren mit: pip install 'iac-" -"code[a2a]'" - #: src/iac_code/cli/main.py msgid "Send a prompt to an A2A JSON-RPC endpoint." msgstr "Sendet einen Prompt an einen A2A-JSON-RPC-Endpunkt." @@ -1639,7 +1657,8 @@ msgid "cleanup prompt" msgstr "Bereinigungs-Prompt" #: src/iac_code/commands/prompt.py -msgid "cleanup prompt · 已移除" +#, fuzzy +msgid "cleanup prompt · removed" msgstr "Bereinigungs-Prompt · entfernt" #: src/iac_code/commands/prompt.py @@ -2234,109 +2253,132 @@ msgid "User cancelled ask_user_question." msgstr "Der Benutzer hat ask_user_question abgebrochen." #: src/iac_code/pipeline/engine/cleanup.py -msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +msgid "" +"Cloud resources still need cleanup after pipeline rollback. Clean them up" +" now and keep checking until deletion completes." msgstr "" -"Es wurden Cloud-Ressourcen erkannt, die nach dem Pipeline-Rollback noch " -"bereinigt werden müssen. Bereinigen Sie sie sofort und prüfen Sie weiter," -" bis das Löschen abgeschlossen ist." +"Cloud-Ressourcen müssen nach dem Pipeline-Rollback noch bereinigt werden." +" Bereinigen Sie sie jetzt und prüfen Sie weiter, bis die Löschung " +"abgeschlossen ist." #: src/iac_code/pipeline/engine/cleanup.py -msgid "要求:" -msgstr "Anforderungen:" +#, fuzzy +msgid "Requirements:" +msgstr "erforderlich" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +msgid "" +"- Cleanup scope is a strict allowlist: delete only ids in the cleanup " +"resources list below." msgstr "" -"- Der Bereinigungsumfang ist eine strikte Positivliste: Es dürfen nur die" -" IDs in der folgenden Liste „Zu bereinigende Ressourcen“ gelöscht werden." +"- Der Bereinigungsumfang ist eine strikte Allowlist: Löschen Sie nur die " +"IDs in der unten stehenden Bereinigungsressourcenliste." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +msgid "" +"- Do not delete, modify, or roll back any stack or cloud resource outside" +" the cleanup resources list." msgstr "" "- Löschen, ändern oder rollen Sie keine Stacks oder Cloud-Ressourcen " -"zurück, die nicht unter „Zu bereinigende Ressourcen“ aufgeführt sind." +"außerhalb der Bereinigungsressourcenliste zurück." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +msgid "" +"- Do not call ListStacks or search for other stacks by name; cleanup " +"resource ids are fully listed." msgstr "" -"- ListStacks nicht aufrufen und nicht nach anderen Stacks per Name " -"suchen; die IDs der zu bereinigenden Ressourcen sind vollstaendig " -"aufgelistet." +"- Rufen Sie ListStacks nicht auf und suchen Sie nicht nach anderen Stacks" +" anhand des Namens; die Bereinigungsressourcen-IDs sind vollständig " +"aufgeführt." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +msgid "" +"- Before every GetStack/DeleteStack call, verify that StackId exactly " +"matches an id in the cleanup resources list." msgstr "" -"- Vor jedem Aufruf von GetStack/DeleteStack muss geprueft werden, dass " -"StackId exakt einer ID in der Liste der zu bereinigenden Ressourcen " -"entspricht." +"- Prüfen Sie vor jedem GetStack/DeleteStack-Aufruf, dass die StackId " +"genau mit einer ID in der Bereinigungsressourcenliste übereinstimmt." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +msgid "" +"- If StackId is not in the cleanup resources list, do not call " +"DeleteStack, even if it is the current handoff or newly created stack." msgstr "" -"- Wenn StackId nicht in der Liste der zu bereinigenden Ressourcen steht, " -"darf DeleteStack nicht aufgerufen werden, selbst wenn es der aktuelle " -"Handoff-Stack oder ein gerade erstellter Stack ist." +"- Wenn die StackId nicht in der Bereinigungsressourcenliste steht, rufen " +"Sie DeleteStack nicht auf, selbst wenn es der aktuelle Handoff- oder neu " +"erstellte Stack ist." #: src/iac_code/pipeline/engine/cleanup.py msgid "" -"- 不要根据 pipeline handoff、deployment.stack_id、current stack 或 " -"resources_created 额外推断清理对象;这些可能是最终成功交付的资源。" +"- Do not infer extra cleanup targets from pipeline handoff, " +"deployment.stack_id, current stack, or resources_created; those may be " +"final delivered resources." msgstr "" -"- Leiten Sie keine zusätzlichen Bereinigungsobjekte aus pipeline handoff," -" deployment.stack_id, current stack oder resources_created ab; sie können" -" erfolgreich bereitgestellte Endressourcen sein." +"- Leiten Sie keine zusätzlichen Bereinigungsziele aus Pipeline-Handoff, " +"deployment.stack_id, aktuellem Stack oder resources_created ab; das " +"können finale Auslieferungsressourcen sein." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +msgid "" +"- Do not expand cleanup scope for user follow-ups, continue instructions," +" or pipeline handoff context." msgstr "" -"- Auch bei weiteren Nutzerfragen, Fortsetzungsanweisungen oder Pipeline-" -"Handoff-Kontext in dieser Runde darf der Bereinigungsumfang nicht " -"erweitert werden." +"- Erweitern Sie den Bereinigungsumfang nicht aufgrund von Benutzer-" +"Rückfragen, Continue-Anweisungen oder Pipeline-Handoff-Kontext." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +msgid "" +"- When resuming cleanup, still process only resources listed in this " +"prompt; do not inspect or delete others." msgstr "" -"- Beim Wiederaufnehmen oder Fortsetzen der Bereinigung nur die in diesem " -"Hinweis aufgelisteten Ressourcen verarbeiten; keine anderen Ressourcen " -"pruefen oder loeschen." +"- Verarbeiten Sie beim Fortsetzen der Bereinigung weiterhin nur die in " +"diesem Prompt aufgeführten Ressourcen; prüfen oder löschen Sie keine " +"anderen." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 优先使用可用的 ROS stack 工具删除;如果改用 aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +msgid "" +"- Prefer available ROS stack tools for deletion; if using aliyun_api, " +"call DeleteStack first, then repeatedly call GetStack to check status." msgstr "" -"- Verwenden Sie bevorzugt das verfügbare ROS-stack-Tool zum Löschen; wenn" -" Sie aliyun_api nutzen, führen Sie zuerst DeleteStack aus und prüfen Sie " -"den Status wiederholt mit GetStack." +"- Verwenden Sie für die Löschung bevorzugt verfügbare ROS-Stack-Tools; " +"wenn Sie aliyun_api verwenden, rufen Sie zuerst DeleteStack auf und " +"anschließend wiederholt GetStack, um den Status zu prüfen." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +msgid "" +"- If a resource is already deleting, call GetStack first, then decide " +"whether DeleteStack is needed again." msgstr "" -"- Wenn die Ressource bereits gelöscht wird, prüfen Sie zuerst den " -"aktuellen Status mit GetStack, bevor Sie entscheiden, ob DeleteStack " -"erneut nötig ist." +"- Wenn eine Ressource bereits gelöscht wird, rufen Sie zuerst GetStack " +"auf und entscheiden Sie dann, ob DeleteStack erneut nötig ist." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +msgid "" +"- Cleanup is complete only after DELETE_COMPLETE; for DELETE_FAILED or " +"unknown status, tell the user the failure reason and next step." msgstr "" -"- Die Bereinigung gilt erst nach Bestätigung von DELETE_COMPLETE als " -"abgeschlossen; bei DELETE_FAILED oder fehlender Bestätigung erklären Sie " -"dem Nutzer die Fehlerursache und den nächsten Schritt." +"- Die Bereinigung ist erst nach DELETE_COMPLETE abgeschlossen; bei " +"DELETE_FAILED oder unbekanntem Status nennen Sie dem Benutzer den " +"Fehlergrund und den nächsten Schritt." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +msgid "" +"- After all listed resources are DELETE_COMPLETE, stop this cleanup turn " +"immediately." msgstr "" -"- Sobald alle Ressourcen in der Liste DELETE_COMPLETE erreicht haben, " -"stoppen Sie diese Bereinigung sofort; löschen oder prüfen Sie keine " -"anderen Stacks." +"- Nachdem alle aufgeführten Ressourcen DELETE_COMPLETE erreicht haben, " +"beenden Sie diesen Bereinigungsschritt sofort." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理过程中向用户简短同步进度。" +msgid "- Briefly update the user during cleanup." msgstr "" -"- Informieren Sie den Nutzer während der Bereinigung kurz über den " +"- Informieren Sie den Benutzer während der Bereinigung kurz über den " "Fortschritt." #: src/iac_code/pipeline/engine/cleanup.py -msgid "待清理资源:" -msgstr "Zu bereinigende Ressourcen:" +#, fuzzy +msgid "Cleanup resources:" +msgstr "Bereinigungs-Prompts" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2349,10 +2391,10 @@ msgstr "" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format -msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +msgid "Detected {count} rollback cleanup resources; starting cleanup." msgstr "" -"{count} verbleibende Rollback-Ressourcen erkannt; Bereinigungsablauf wird" -" gestartet." +"{count} Rollback-Bereinigungsressourcen erkannt; Bereinigung wird " +"gestartet." #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" @@ -2474,7 +2516,7 @@ msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" -msgstr "" +msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2558,6 +2600,11 @@ msgstr "" msgid "Step {step_id} completed. Conclusion submitted." msgstr "Schritt {step_id} abgeschlossen. Schlussfolgerung übermittelt." +#: src/iac_code/pipeline/engine/pipeline_runner.py +#, fuzzy +msgid "Pipeline state persistence failed." +msgstr "A2A-Pipeline-Zustand nicht gefunden" + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -2658,6 +2705,10 @@ msgstr "Der Template-Dateipfad muss relativ zum Arbeitsverzeichnis sein" msgid "Template file path cannot escape the working directory" msgstr "Der Template-Dateipfad darf das Arbeitsverzeichnis nicht verlassen" +#: src/iac_code/pipeline/engine/user_input.py +msgid "[Image input]" +msgstr "[Bildeingabe]" + #: src/iac_code/pipeline/selling/tools/show_candidate_detail_tool.py msgid "" "Display candidate details (summary and cost breakdown) in the comparison " @@ -3673,11 +3724,11 @@ msgstr "Unterbrochen" msgid "Running" msgstr "Läuft" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Completed" msgstr "Abgeschlossen" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Failed" msgstr "Fehlgeschlagen" @@ -3954,8 +4005,8 @@ msgid "Command has no handler: {name}" msgstr "Kein Handler für Befehl: {name}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "↺ 回滚清理 [{badge}] {label}" +#, fuzzy, python-brace-format +msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ Rollback-Bereinigung [{badge}] {label}" #: src/iac_code/ui/repl.py @@ -3964,114 +4015,121 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "错误:{error}" -msgstr "Fehler: {error}" - -#: src/iac_code/ui/repl.py -msgid "删除中" -msgstr "Wird gelöscht" - -#: src/iac_code/ui/repl.py -msgid "完成" -msgstr "Abgeschlossen" - -#: src/iac_code/ui/repl.py -msgid "失败" -msgstr "Fehlgeschlagen" +#, fuzzy +msgid "Deleting" +msgstr "Bereitstellung" #: src/iac_code/ui/repl.py -msgid "跳过" -msgstr "Übersprungen" +#, fuzzy +msgid "Skipped" +msgstr "Überspringen" #: src/iac_code/ui/repl.py -msgid "待处理" -msgstr "Ausstehend" +#, fuzzy +msgid "Pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -msgid "检查" -msgstr "Prüfen" +#, fuzzy +msgid "Checking" +msgstr "PRÜFUNG LÄUFT" #: src/iac_code/ui/repl.py -msgid "进度" -msgstr "Fortschritt" +#, fuzzy +msgid "Progress" +msgstr "Verarbeitet" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "DeleteStack 已提交,等待删除完成({progress})" -msgstr "" -"DeleteStack wurde übermittelt; warte auf Abschluss des Löschvorgangs " -"({progress})" - -#: src/iac_code/ui/repl.py -msgid "DeleteStack 已提交,等待删除完成" -msgstr "DeleteStack wurde übermittelt; warte auf Abschluss des Löschvorgangs" +msgid "DeleteStack submitted; waiting for deletion to complete ({progress})" +msgstr "DeleteStack übermittelt; warte auf Abschluss der Löschung ({progress})" #: src/iac_code/ui/repl.py -msgid "已跳过" -msgstr "Übersprungen" +msgid "DeleteStack submitted; waiting for deletion to complete" +msgstr "DeleteStack übermittelt; warte auf Abschluss der Löschung" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "正在删除({progress})" +msgid "Deleting ({progress})" msgstr "Wird gelöscht ({progress})" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "{progress},需要删除" -msgstr "{progress}, Löschen erforderlich" +msgid "{progress}; deletion required" +msgstr "{progress}; Löschung erforderlich" #: src/iac_code/ui/repl.py -msgid "资源栈" -msgstr "Ressourcen-Stack" +#, fuzzy +msgid "stack" +msgstr "ROS Stack" #: src/iac_code/ui/repl.py -msgid "资源" +#, fuzzy +msgid "resource" msgstr "Ressource" #: src/iac_code/ui/repl.py -msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +msgid "" +"Could not read rollback cleanup records. The cleanup prompt was kept; " +"retry later or inspect manually." msgstr "" -"Das Rollback-Bereinigungsprotokoll konnte nicht gelesen werden; der " -"Bereinigungs-Prompt wurde beibehalten. Fahren Sie später fort oder prüfen" -" Sie manuell." +"Rollback-Bereinigungsdatensätze konnten nicht gelesen werden. Der " +"Bereinigungs-Prompt wurde beibehalten; versuchen Sie es später erneut " +"oder prüfen Sie manuell." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "还有 {count} 个需要关注的资源未显示。" +msgid "{count} additional resources needing attention were not shown." msgstr "" -"{count} weitere Ressourcen, die Aufmerksamkeit erfordern, werden nicht " +"{count} weitere Ressourcen, die Aufmerksamkeit erfordern, wurden nicht " "angezeigt." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +msgid "↺ Rollback cleanup resume: all {count} records are completed." msgstr "" -"↺ Wiederaufnahme der Rollback-Bereinigung: Alle {count} Einträge sind " +"↺ Rollback-Bereinigung fortsetzen: Alle {count} Datensätze sind " "abgeschlossen." #: src/iac_code/ui/repl.py -msgid "进行中" -msgstr "In Bearbeitung" +#, fuzzy +msgid "failed" +msgstr "Fehlgeschlagen" #: src/iac_code/ui/repl.py -msgid "已完成" +#, fuzzy +msgid "pending" +msgstr "OpenAI" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "in progress" +msgstr "PRÜFUNG LÄUFT" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "completed" msgstr "Abgeschlossen" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "{count} 条{label}" +#, fuzzy +msgid "skipped" +msgstr "Überspringen" + +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "{count} {label}" msgstr "{count} {label}" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" -msgstr "↺ Wiederaufnahme der Rollback-Bereinigung: {count} Einträge, {summary}." +msgid "↺ Rollback cleanup resume: {count} records, {summary}." +msgstr "↺ Rollback-Bereinigung fortsetzen: {count} Datensätze, {summary}." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录。" -msgstr "↺ Wiederaufnahme der Rollback-Bereinigung: {count} Einträge." +msgid "↺ Rollback cleanup resume: {count} records." +msgstr "↺ Rollback-Bereinigung fortsetzen: {count} Datensätze." #: src/iac_code/ui/repl.py #, python-brace-format @@ -4084,10 +4142,6 @@ msgstr "" "Rollback-Bereinigungsressourcen wurden erkannt, aber das Einfügen des " "Bereinigungs-Prompts ist fehlgeschlagen." -#: src/iac_code/ui/repl.py -msgid "Note: images are not supported in pipeline mode and will be ignored." -msgstr "Hinweis: Bilder werden im Pipeline-Modus nicht unterstützt und ignoriert." - #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -4095,8 +4149,26 @@ msgstr "Gespeicherter Pipeline-Zustand wird ignoriert: {reason}" #: src/iac_code/ui/repl.py msgid "" -"Pipeline completed. Normal chat is active, but the handoff context could " -"not be injected or saved." +"Pipeline state persistence failed. The pipeline is paused; do not " +"continue until state is durable." +msgstr "" +"Persistenz des Pipeline-Zustands fehlgeschlagen. Die Pipeline ist " +"pausiert; fahren Sie nicht fort, bis der Zustand dauerhaft gespeichert " +"ist." + +#: src/iac_code/ui/repl.py +msgid "" +"Pipeline state persistence failed. Normal chat handoff was not marked " +"durable." +msgstr "" +"Persistenz des Pipeline-Zustands fehlgeschlagen. Der Handoff zum normalen" +" Chat wurde nicht dauerhaft markiert." + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "" +"Pipeline completed, but the handoff context could not be injected or " +"saved." msgstr "" "Die Pipeline wurde abgeschlossen. Der normale Chat ist aktiv, aber der " "Übergabekontext konnte nicht eingefügt oder gespeichert werden." @@ -4360,6 +4432,16 @@ msgstr "unbekannter Fehler" msgid "Resumed pipeline at step: {step}" msgstr "Pipeline bei Schritt fortgesetzt: {step}" +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "Could not read pipeline state metadata: {reason}" +msgstr "Pipeline-Zustand konnte nicht fortgesetzt werden: {reason}" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "Pipeline state metadata is invalid; continuing as normal chat." +msgstr "Pipeline-Zustand verwerfen und als normalen Chat fortfahren" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Found pipeline state in this session (paused at: {step})." @@ -4922,3 +5004,207 @@ msgstr "Stacktrace im öffentlichen Ereignis ausgelassen; siehe error_id." #~ msgid "Project Memory Index" #~ msgstr "Projektspeicherindex" + +#~ msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +#~ msgstr "" +#~ "Es wurden Cloud-Ressourcen erkannt, die" +#~ " nach dem Pipeline-Rollback noch " +#~ "bereinigt werden müssen. Bereinigen Sie " +#~ "sie sofort und prüfen Sie weiter, " +#~ "bis das Löschen abgeschlossen ist." + +#~ msgid "要求:" +#~ msgstr "Anforderungen:" + +#~ msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +#~ msgstr "" +#~ "- Der Bereinigungsumfang ist eine " +#~ "strikte Positivliste: Es dürfen nur die" +#~ " IDs in der folgenden Liste „Zu " +#~ "bereinigende Ressourcen“ gelöscht werden." + +#~ msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +#~ msgstr "" +#~ "- Löschen, ändern oder rollen Sie " +#~ "keine Stacks oder Cloud-Ressourcen " +#~ "zurück, die nicht unter „Zu bereinigende" +#~ " Ressourcen“ aufgeführt sind." + +#~ msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +#~ msgstr "" +#~ "- ListStacks nicht aufrufen und nicht" +#~ " nach anderen Stacks per Name suchen;" +#~ " die IDs der zu bereinigenden " +#~ "Ressourcen sind vollstaendig aufgelistet." + +#~ msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +#~ msgstr "" +#~ "- Vor jedem Aufruf von " +#~ "GetStack/DeleteStack muss geprueft werden, " +#~ "dass StackId exakt einer ID in der" +#~ " Liste der zu bereinigenden Ressourcen " +#~ "entspricht." + +#~ msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +#~ msgstr "" +#~ "- Wenn StackId nicht in der Liste" +#~ " der zu bereinigenden Ressourcen steht, " +#~ "darf DeleteStack nicht aufgerufen werden, " +#~ "selbst wenn es der aktuelle Handoff-" +#~ "Stack oder ein gerade erstellter Stack" +#~ " ist." + +#~ msgid "" +#~ "- 不要根据 pipeline handoff、deployment.stack_id、current" +#~ " stack 或 resources_created " +#~ "额外推断清理对象;这些可能是最终成功交付的资源。" +#~ msgstr "" +#~ "- Leiten Sie keine zusätzlichen " +#~ "Bereinigungsobjekte aus pipeline handoff, " +#~ "deployment.stack_id, current stack oder " +#~ "resources_created ab; sie können erfolgreich" +#~ " bereitgestellte Endressourcen sein." + +#~ msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +#~ msgstr "" +#~ "- Auch bei weiteren Nutzerfragen, " +#~ "Fortsetzungsanweisungen oder Pipeline-Handoff-" +#~ "Kontext in dieser Runde darf der " +#~ "Bereinigungsumfang nicht erweitert werden." + +#~ msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +#~ msgstr "" +#~ "- Beim Wiederaufnehmen oder Fortsetzen " +#~ "der Bereinigung nur die in diesem " +#~ "Hinweis aufgelisteten Ressourcen verarbeiten; " +#~ "keine anderen Ressourcen pruefen oder " +#~ "loeschen." + +#~ msgid "" +#~ "- 优先使用可用的 ROS stack 工具删除;如果改用 " +#~ "aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +#~ msgstr "" +#~ "- Verwenden Sie bevorzugt das verfügbare" +#~ " ROS-stack-Tool zum Löschen; wenn " +#~ "Sie aliyun_api nutzen, führen Sie zuerst" +#~ " DeleteStack aus und prüfen Sie den" +#~ " Status wiederholt mit GetStack." + +#~ msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +#~ msgstr "" +#~ "- Wenn die Ressource bereits gelöscht" +#~ " wird, prüfen Sie zuerst den " +#~ "aktuellen Status mit GetStack, bevor Sie" +#~ " entscheiden, ob DeleteStack erneut nötig" +#~ " ist." + +#~ msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +#~ msgstr "" +#~ "- Die Bereinigung gilt erst nach " +#~ "Bestätigung von DELETE_COMPLETE als " +#~ "abgeschlossen; bei DELETE_FAILED oder " +#~ "fehlender Bestätigung erklären Sie dem " +#~ "Nutzer die Fehlerursache und den " +#~ "nächsten Schritt." + +#~ msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +#~ msgstr "" +#~ "- Sobald alle Ressourcen in der " +#~ "Liste DELETE_COMPLETE erreicht haben, stoppen" +#~ " Sie diese Bereinigung sofort; löschen " +#~ "oder prüfen Sie keine anderen Stacks." + +#~ msgid "- 清理过程中向用户简短同步进度。" +#~ msgstr "" +#~ "- Informieren Sie den Nutzer während " +#~ "der Bereinigung kurz über den " +#~ "Fortschritt." + +#~ msgid "待清理资源:" +#~ msgstr "Zu bereinigende Ressourcen:" + +#~ msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +#~ msgstr "" +#~ "{count} verbleibende Rollback-Ressourcen " +#~ "erkannt; Bereinigungsablauf wird gestartet." + +#~ msgid "错误:{error}" +#~ msgstr "Fehler: {error}" + +#~ msgid "删除中" +#~ msgstr "Wird gelöscht" + +#~ msgid "完成" +#~ msgstr "Abgeschlossen" + +#~ msgid "失败" +#~ msgstr "Fehlgeschlagen" + +#~ msgid "跳过" +#~ msgstr "Übersprungen" + +#~ msgid "待处理" +#~ msgstr "Ausstehend" + +#~ msgid "检查" +#~ msgstr "Prüfen" + +#~ msgid "进度" +#~ msgstr "Fortschritt" + +#~ msgid "DeleteStack 已提交,等待删除完成({progress})" +#~ msgstr "" +#~ "DeleteStack wurde übermittelt; warte auf " +#~ "Abschluss des Löschvorgangs ({progress})" + +#~ msgid "DeleteStack 已提交,等待删除完成" +#~ msgstr "DeleteStack wurde übermittelt; warte auf Abschluss des Löschvorgangs" + +#~ msgid "已跳过" +#~ msgstr "Übersprungen" + +#~ msgid "正在删除({progress})" +#~ msgstr "Wird gelöscht ({progress})" + +#~ msgid "{progress},需要删除" +#~ msgstr "{progress}, Löschen erforderlich" + +#~ msgid "资源栈" +#~ msgstr "Ressourcen-Stack" + +#~ msgid "资源" +#~ msgstr "Ressource" + +#~ msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +#~ msgstr "" +#~ "Das Rollback-Bereinigungsprotokoll konnte " +#~ "nicht gelesen werden; der Bereinigungs-" +#~ "Prompt wurde beibehalten. Fahren Sie " +#~ "später fort oder prüfen Sie manuell." + +#~ msgid "还有 {count} 个需要关注的资源未显示。" +#~ msgstr "" +#~ "{count} weitere Ressourcen, die Aufmerksamkeit" +#~ " erfordern, werden nicht angezeigt." + +#~ msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +#~ msgstr "" +#~ "↺ Wiederaufnahme der Rollback-Bereinigung: " +#~ "Alle {count} Einträge sind abgeschlossen." + +#~ msgid "进行中" +#~ msgstr "In Bearbeitung" + +#~ msgid "已完成" +#~ msgstr "Abgeschlossen" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" +#~ msgstr "↺ Wiederaufnahme der Rollback-Bereinigung: {count} Einträge, {summary}." + +#~ msgid "↺ 回滚清理恢复:{count} 条记录。" +#~ msgstr "↺ Wiederaufnahme der Rollback-Bereinigung: {count} Einträge." + +#~ msgid "Note: images are not supported in pipeline mode and will be ignored." +#~ msgstr "" +#~ "Hinweis: Bilder werden im Pipeline-Modus" +#~ " nicht unterstützt und ignoriert." diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index f17c4f88..dbe71cea 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -49,6 +49,14 @@ msgstr "Nombre de archivo de artefacto no seguro" msgid "Unknown error" msgstr "Error desconocido" +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "" +"Cleanup state unavailable. Inspect the session file and cloud resources " +"manually." +msgstr "" +"Estado de limpieza no disponible. Inspeccione manualmente el archivo de " +"sesión y los recursos en la nube." + #: src/iac_code/a2a/executor.py msgid "" "Rollback cleanup deferred prompt state is unavailable. Please repair it " @@ -82,6 +90,11 @@ msgstr "La tarea ya está en ejecución." msgid "Task canceled." msgstr "Tarea cancelada." +#: src/iac_code/a2a/executor.py +#, fuzzy, python-brace-format +msgid "Current model {model} does not support image input." +msgstr "El modelo {model} no admite effort." + #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." msgstr "Se produjo un error temporal. Inténtalo de nuevo." @@ -92,6 +105,11 @@ msgstr "" "Se requiere autenticación. Configura las credenciales e inténtalo de " "nuevo." +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "Pipeline already running. Resume task {task_id}." +msgstr "La pipeline ya se está ejecutando. Reanude la tarea {task_id}." + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "No se encontró el estado del pipeline A2A" @@ -528,6 +546,22 @@ msgstr "Herramienta de orquestación de infraestructura asistida por IA" msgid "Use iac-code as an A2A client." msgstr "Usa iac-code como cliente A2A." +#: src/iac_code/cli/main.py +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"Faltan las dependencias del cliente A2A. Instálalas con: pip install " +"'iac-code[a2a]'" + +#: src/iac_code/cli/main.py +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"Faltan las dependencias del servidor A2A. Instálalas con: pip install " +"'iac-code[a2a]'" + #: src/iac_code/cli/main.py msgid "Install Git for Windows via the npmmirror mirror (Windows only)." msgstr "Instalar Git for Windows mediante el espejo npmmirror (solo Windows)." @@ -540,14 +574,6 @@ msgstr "Actualizar iac-code a la última versión." msgid "YAML config file containing A2A client options" msgstr "Archivo de configuración YAML con opciones del cliente A2A" -#: src/iac_code/cli/main.py -msgid "" -"A2A client dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "" -"Faltan las dependencias del cliente A2A. Instálalas con: pip install " -"'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "LLM model to use" msgstr "Modelo LLM a utilizar" @@ -681,14 +707,6 @@ msgstr "" "Expone tipos de señal de thinking A2A; repite para varios. Valores: raw-" "thinking, tool-trace." -#: src/iac_code/cli/main.py -msgid "" -"A2A server dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "" -"Faltan las dependencias del servidor A2A. Instálalas con: pip install " -"'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "Send a prompt to an A2A JSON-RPC endpoint." msgstr "Envía un prompt a un endpoint JSON-RPC A2A." @@ -1640,7 +1658,8 @@ msgid "cleanup prompt" msgstr "prompt de limpieza" #: src/iac_code/commands/prompt.py -msgid "cleanup prompt · 已移除" +#, fuzzy +msgid "cleanup prompt · removed" msgstr "prompt de limpieza · eliminado" #: src/iac_code/commands/prompt.py @@ -2239,104 +2258,127 @@ msgid "User cancelled ask_user_question." msgstr "El usuario canceló ask_user_question." #: src/iac_code/pipeline/engine/cleanup.py -msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +msgid "" +"Cloud resources still need cleanup after pipeline rollback. Clean them up" +" now and keep checking until deletion completes." msgstr "" -"Se detectaron recursos en la nube que aún deben limpiarse después del " -"rollback del pipeline. Límpielos de inmediato y siga comprobando hasta " -"que la eliminación termine." +"Los recursos en la nube aún necesitan limpieza después del rollback de la" +" pipeline. Límpielos ahora y siga comprobando hasta que la eliminación se" +" complete." #: src/iac_code/pipeline/engine/cleanup.py -msgid "要求:" -msgstr "Requisitos:" +#, fuzzy +msgid "Requirements:" +msgstr "obligatorio" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +msgid "" +"- Cleanup scope is a strict allowlist: delete only ids in the cleanup " +"resources list below." msgstr "" -"- El alcance de limpieza es una lista blanca estricta: solo se pueden " -"eliminar los id de la lista \"Recursos por limpiar\" siguiente." +"- El alcance de limpieza es una lista permitida estricta: elimine solo " +"los ID de la lista de recursos de limpieza siguiente." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +msgid "" +"- Do not delete, modify, or roll back any stack or cloud resource outside" +" the cleanup resources list." msgstr "" -"- No elimine, modifique ni revierta ningún stack o recurso de nube que no" -" esté en \"Recursos por limpiar\"." +"- No elimine, modifique ni revierta ningún stack o recurso en la nube " +"fuera de la lista de recursos de limpieza." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +msgid "" +"- Do not call ListStacks or search for other stacks by name; cleanup " +"resource ids are fully listed." msgstr "" -"- No llames a ListStacks ni busques otros stacks por nombre; los id de " -"recursos pendientes de limpieza ya estan listados por completo." +"- No llame a ListStacks ni busque otros stacks por nombre; los ID de " +"recursos de limpieza están completamente listados." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +msgid "" +"- Before every GetStack/DeleteStack call, verify that StackId exactly " +"matches an id in the cleanup resources list." msgstr "" -"- Antes de cada llamada a GetStack/DeleteStack, debes comprobar que " -"StackId coincida exactamente con algun id de la lista de recursos " -"pendientes de limpieza." +"- Antes de cada llamada GetStack/DeleteStack, verifique que StackId " +"coincida exactamente con un ID de la lista de recursos de limpieza." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +msgid "" +"- If StackId is not in the cleanup resources list, do not call " +"DeleteStack, even if it is the current handoff or newly created stack." msgstr "" -"- Si StackId no esta en la lista de recursos pendientes de limpieza, esta" -" prohibido llamar a DeleteStack, aunque sea el stack del handoff actual o" -" recien creado." +"- Si StackId no está en la lista de recursos de limpieza, no llame a " +"DeleteStack, aunque sea el stack actual del handoff o uno recién creado." #: src/iac_code/pipeline/engine/cleanup.py msgid "" -"- 不要根据 pipeline handoff、deployment.stack_id、current stack 或 " -"resources_created 额外推断清理对象;这些可能是最终成功交付的资源。" +"- Do not infer extra cleanup targets from pipeline handoff, " +"deployment.stack_id, current stack, or resources_created; those may be " +"final delivered resources." msgstr "" -"- No infiera objetos de limpieza adicionales desde pipeline handoff, " -"deployment.stack_id, current stack o resources_created; podrían ser " -"recursos entregados correctamente al final." +"- No infiera objetivos de limpieza adicionales desde el handoff de la " +"pipeline, deployment.stack_id, el stack actual o resources_created; " +"pueden ser recursos entregados finales." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +msgid "" +"- Do not expand cleanup scope for user follow-ups, continue instructions," +" or pipeline handoff context." msgstr "" -"- Aunque haya preguntas del usuario, instrucciones para continuar o " -"contexto de pipeline handoff en esta ronda, no amplíe el alcance de " -"limpieza." +"- No amplíe el alcance de limpieza por seguimientos del usuario, " +"instrucciones de continuación o contexto de handoff de la pipeline." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +msgid "" +"- When resuming cleanup, still process only resources listed in this " +"prompt; do not inspect or delete others." msgstr "" -"- Al reanudar o continuar la limpieza, procesa solo los recursos listados" -" en este aviso; no compruebes ni elimines otros recursos." +"- Al reanudar la limpieza, procese solo los recursos listados en este " +"prompt; no inspeccione ni elimine otros." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 优先使用可用的 ROS stack 工具删除;如果改用 aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +msgid "" +"- Prefer available ROS stack tools for deletion; if using aliyun_api, " +"call DeleteStack first, then repeatedly call GetStack to check status." msgstr "" -"- Use preferentemente la herramienta ROS stack disponible para eliminar; " -"si usa aliyun_api, ejecute primero DeleteStack y luego GetStack " -"repetidamente para comprobar el estado." +"- Prefiera las herramientas de stack ROS disponibles para eliminar; si " +"usa aliyun_api, llame primero a DeleteStack y luego llame repetidamente a" +" GetStack para comprobar el estado." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +msgid "" +"- If a resource is already deleting, call GetStack first, then decide " +"whether DeleteStack is needed again." msgstr "" -"- Si el recurso ya está en eliminación, use primero GetStack para " -"comprobar el estado actual antes de decidir si debe ejecutar DeleteStack " -"otra vez." +"- Si un recurso ya se está eliminando, llame primero a GetStack y luego " +"decida si hace falta otra llamada DeleteStack." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +msgid "" +"- Cleanup is complete only after DELETE_COMPLETE; for DELETE_FAILED or " +"unknown status, tell the user the failure reason and next step." msgstr "" -"- La limpieza solo se considera completa tras confirmar DELETE_COMPLETE; " -"con DELETE_FAILED o si no se puede confirmar, explique al usuario el " -"motivo del fallo y el siguiente paso." +"- La limpieza solo se completa tras DELETE_COMPLETE; para DELETE_FAILED o" +" un estado desconocido, indique al usuario el motivo del fallo y el " +"siguiente paso." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +msgid "" +"- After all listed resources are DELETE_COMPLETE, stop this cleanup turn " +"immediately." msgstr "" -"- Cuando todos los recursos de la lista estén en DELETE_COMPLETE, detenga" -" inmediatamente esta limpieza; no elimine ni compruebe ningún otro stack." +"- Cuando todos los recursos listados estén en DELETE_COMPLETE, detenga de" +" inmediato este turno de limpieza." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理过程中向用户简短同步进度。" -msgstr "- Informe brevemente al usuario sobre el progreso durante la limpieza." +msgid "- Briefly update the user during cleanup." +msgstr "- Actualice brevemente al usuario durante la limpieza." #: src/iac_code/pipeline/engine/cleanup.py -msgid "待清理资源:" -msgstr "Recursos por limpiar:" +#, fuzzy +msgid "Cleanup resources:" +msgstr "Prompts de limpieza" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2349,10 +2391,10 @@ msgstr "" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format -msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +msgid "Detected {count} rollback cleanup resources; starting cleanup." msgstr "" -"Se detectaron {count} recursos residuales del rollback; iniciando el " -"flujo de limpieza." +"Se detectaron {count} recursos de limpieza de rollback; iniciando " +"limpieza." #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" @@ -2471,7 +2513,7 @@ msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" -msgstr "" +msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2552,6 +2594,11 @@ msgstr "" msgid "Step {step_id} completed. Conclusion submitted." msgstr "Paso {step_id} completado. Conclusión enviada." +#: src/iac_code/pipeline/engine/pipeline_runner.py +#, fuzzy +msgid "Pipeline state persistence failed." +msgstr "No se encontró el estado del pipeline A2A" + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -2654,6 +2701,10 @@ msgstr "" msgid "Template file path cannot escape the working directory" msgstr "La ruta del archivo de plantilla no puede salir del directorio de trabajo" +#: src/iac_code/pipeline/engine/user_input.py +msgid "[Image input]" +msgstr "[Entrada de imagen]" + #: src/iac_code/pipeline/selling/tools/show_candidate_detail_tool.py msgid "" "Display candidate details (summary and cost breakdown) in the comparison " @@ -3670,11 +3721,11 @@ msgstr "Interrumpido" msgid "Running" msgstr "En ejecución" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Completed" msgstr "Completado" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Failed" msgstr "Error" @@ -3956,8 +4007,8 @@ msgid "Command has no handler: {name}" msgstr "El comando no tiene controlador: {name}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "↺ 回滚清理 [{badge}] {label}" +#, fuzzy, python-brace-format +msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ Limpieza de rollback [{badge}] {label}" #: src/iac_code/ui/repl.py @@ -3966,109 +4017,120 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "错误:{error}" -msgstr "Error: {error}" - -#: src/iac_code/ui/repl.py -msgid "删除中" -msgstr "Eliminando" - -#: src/iac_code/ui/repl.py -msgid "完成" -msgstr "Completado" - -#: src/iac_code/ui/repl.py -msgid "失败" -msgstr "Fallido" +#, fuzzy +msgid "Deleting" +msgstr "Despliegue" #: src/iac_code/ui/repl.py -msgid "跳过" -msgstr "Omitido" +#, fuzzy +msgid "Skipped" +msgstr "Omitir" #: src/iac_code/ui/repl.py -msgid "待处理" -msgstr "Pendiente" +#, fuzzy +msgid "Pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -msgid "检查" -msgstr "Comprobar" +#, fuzzy +msgid "Checking" +msgstr "Comprobación en curso" #: src/iac_code/ui/repl.py -msgid "进度" -msgstr "Progreso" +#, fuzzy +msgid "Progress" +msgstr "Procesado" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "DeleteStack 已提交,等待删除完成({progress})" -msgstr "DeleteStack enviado; esperando que termine la eliminación ({progress})" - -#: src/iac_code/ui/repl.py -msgid "DeleteStack 已提交,等待删除完成" -msgstr "DeleteStack enviado; esperando que termine la eliminación" +msgid "DeleteStack submitted; waiting for deletion to complete ({progress})" +msgstr "" +"DeleteStack enviado; esperando a que se complete la eliminación " +"({progress})" #: src/iac_code/ui/repl.py -msgid "已跳过" -msgstr "Omitido" +msgid "DeleteStack submitted; waiting for deletion to complete" +msgstr "DeleteStack enviado; esperando a que se complete la eliminación" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "正在删除({progress})" +msgid "Deleting ({progress})" msgstr "Eliminando ({progress})" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "{progress},需要删除" -msgstr "{progress}, requiere eliminación" +msgid "{progress}; deletion required" +msgstr "{progress}; se requiere eliminación" #: src/iac_code/ui/repl.py -msgid "资源栈" -msgstr "Stack de recursos" +#, fuzzy +msgid "stack" +msgstr "ROS Stack" #: src/iac_code/ui/repl.py -msgid "资源" +#, fuzzy +msgid "resource" msgstr "Recurso" #: src/iac_code/ui/repl.py -msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +msgid "" +"Could not read rollback cleanup records. The cleanup prompt was kept; " +"retry later or inspect manually." msgstr "" -"No se pudo leer el registro de limpieza de rollback; se conservó el " -"prompt de limpieza. Continúe más tarde o revise manualmente." +"No se pudieron leer los registros de limpieza de rollback. Se conservó el" +" prompt de limpieza; reintente más tarde o inspeccione manualmente." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "还有 {count} 个需要关注的资源未显示。" -msgstr "Hay {count} recursos más que requieren atención y no se muestran." +msgid "{count} additional resources needing attention were not shown." +msgstr "No se mostraron {count} recursos adicionales que requieren atención." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +msgid "↺ Rollback cleanup resume: all {count} records are completed." msgstr "" -"↺ Recuperación de limpieza de rollback: las {count} entradas están " -"completadas." +"↺ Reanudación de limpieza de rollback: los {count} registros están " +"completados." #: src/iac_code/ui/repl.py -msgid "进行中" -msgstr "En curso" +#, fuzzy +msgid "failed" +msgstr "Error" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -msgid "已完成" +#, fuzzy +msgid "in progress" +msgstr "Comprobación en curso" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "completed" msgstr "Completado" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "{count} 条{label}" +#, fuzzy +msgid "skipped" +msgstr "Omitir" + +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "{count} {label}" msgstr "{count} {label}" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" -msgstr "↺ Recuperación de limpieza de rollback: {count} entradas, {summary}." +msgid "↺ Rollback cleanup resume: {count} records, {summary}." +msgstr "↺ Reanudación de limpieza de rollback: {count} registros, {summary}." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录。" -msgstr "↺ Recuperación de limpieza de rollback: {count} entradas." +msgid "↺ Rollback cleanup resume: {count} records." +msgstr "↺ Reanudación de limpieza de rollback: {count} registros." #: src/iac_code/ui/repl.py #, python-brace-format @@ -4081,10 +4143,6 @@ msgstr "" "Se detectaron recursos de limpieza de rollback, pero falló la inyección " "del prompt de limpieza." -#: src/iac_code/ui/repl.py -msgid "Note: images are not supported in pipeline mode and will be ignored." -msgstr "Nota: las imágenes no son compatibles con el modo pipeline y se ignorarán." - #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -4092,8 +4150,25 @@ msgstr "Ignorando el estado guardado del pipeline: {reason}" #: src/iac_code/ui/repl.py msgid "" -"Pipeline completed. Normal chat is active, but the handoff context could " -"not be injected or saved." +"Pipeline state persistence failed. The pipeline is paused; do not " +"continue until state is durable." +msgstr "" +"Error al persistir el estado de la pipeline. La pipeline está pausada; no" +" continúe hasta que el estado sea duradero." + +#: src/iac_code/ui/repl.py +msgid "" +"Pipeline state persistence failed. Normal chat handoff was not marked " +"durable." +msgstr "" +"Error al persistir el estado de la pipeline. El handoff al chat normal no" +" se marcó como duradero." + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "" +"Pipeline completed, but the handoff context could not be injected or " +"saved." msgstr "" "El pipeline se completó. El chat normal está activo, pero no se pudo " "inyectar ni guardar el contexto de traspaso." @@ -4352,6 +4427,16 @@ msgstr "error desconocido" msgid "Resumed pipeline at step: {step}" msgstr "Pipeline reanudado en el paso: {step}" +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "Could not read pipeline state metadata: {reason}" +msgstr "No se pudo reanudar el estado del pipeline: {reason}" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "Pipeline state metadata is invalid; continuing as normal chat." +msgstr "Descartar el estado del pipeline y continuar como chat normal" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Found pipeline state in this session (paused at: {step})." @@ -4914,3 +4999,198 @@ msgstr "La traza de pila se omitió del evento público; consulta error_id." #~ msgid "Project Memory Index" #~ msgstr "Índice de memoria del proyecto" + +#~ msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +#~ msgstr "" +#~ "Se detectaron recursos en la nube " +#~ "que aún deben limpiarse después del " +#~ "rollback del pipeline. Límpielos de " +#~ "inmediato y siga comprobando hasta que" +#~ " la eliminación termine." + +#~ msgid "要求:" +#~ msgstr "Requisitos:" + +#~ msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +#~ msgstr "" +#~ "- El alcance de limpieza es una" +#~ " lista blanca estricta: solo se " +#~ "pueden eliminar los id de la lista" +#~ " \"Recursos por limpiar\" siguiente." + +#~ msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +#~ msgstr "" +#~ "- No elimine, modifique ni revierta " +#~ "ningún stack o recurso de nube que" +#~ " no esté en \"Recursos por limpiar\"." + +#~ msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +#~ msgstr "" +#~ "- No llames a ListStacks ni " +#~ "busques otros stacks por nombre; los " +#~ "id de recursos pendientes de limpieza" +#~ " ya estan listados por completo." + +#~ msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +#~ msgstr "" +#~ "- Antes de cada llamada a " +#~ "GetStack/DeleteStack, debes comprobar que " +#~ "StackId coincida exactamente con algun " +#~ "id de la lista de recursos " +#~ "pendientes de limpieza." + +#~ msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +#~ msgstr "" +#~ "- Si StackId no esta en la " +#~ "lista de recursos pendientes de " +#~ "limpieza, esta prohibido llamar a " +#~ "DeleteStack, aunque sea el stack del " +#~ "handoff actual o recien creado." + +#~ msgid "" +#~ "- 不要根据 pipeline handoff、deployment.stack_id、current" +#~ " stack 或 resources_created " +#~ "额外推断清理对象;这些可能是最终成功交付的资源。" +#~ msgstr "" +#~ "- No infiera objetos de limpieza " +#~ "adicionales desde pipeline handoff, " +#~ "deployment.stack_id, current stack o " +#~ "resources_created; podrían ser recursos " +#~ "entregados correctamente al final." + +#~ msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +#~ msgstr "" +#~ "- Aunque haya preguntas del usuario, " +#~ "instrucciones para continuar o contexto " +#~ "de pipeline handoff en esta ronda, " +#~ "no amplíe el alcance de limpieza." + +#~ msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +#~ msgstr "" +#~ "- Al reanudar o continuar la " +#~ "limpieza, procesa solo los recursos " +#~ "listados en este aviso; no compruebes" +#~ " ni elimines otros recursos." + +#~ msgid "" +#~ "- 优先使用可用的 ROS stack 工具删除;如果改用 " +#~ "aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +#~ msgstr "" +#~ "- Use preferentemente la herramienta ROS" +#~ " stack disponible para eliminar; si " +#~ "usa aliyun_api, ejecute primero DeleteStack" +#~ " y luego GetStack repetidamente para " +#~ "comprobar el estado." + +#~ msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +#~ msgstr "" +#~ "- Si el recurso ya está en " +#~ "eliminación, use primero GetStack para " +#~ "comprobar el estado actual antes de " +#~ "decidir si debe ejecutar DeleteStack " +#~ "otra vez." + +#~ msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +#~ msgstr "" +#~ "- La limpieza solo se considera " +#~ "completa tras confirmar DELETE_COMPLETE; con" +#~ " DELETE_FAILED o si no se puede " +#~ "confirmar, explique al usuario el motivo" +#~ " del fallo y el siguiente paso." + +#~ msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +#~ msgstr "" +#~ "- Cuando todos los recursos de la" +#~ " lista estén en DELETE_COMPLETE, detenga" +#~ " inmediatamente esta limpieza; no elimine" +#~ " ni compruebe ningún otro stack." + +#~ msgid "- 清理过程中向用户简短同步进度。" +#~ msgstr "- Informe brevemente al usuario sobre el progreso durante la limpieza." + +#~ msgid "待清理资源:" +#~ msgstr "Recursos por limpiar:" + +#~ msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +#~ msgstr "" +#~ "Se detectaron {count} recursos residuales " +#~ "del rollback; iniciando el flujo de " +#~ "limpieza." + +#~ msgid "错误:{error}" +#~ msgstr "Error: {error}" + +#~ msgid "删除中" +#~ msgstr "Eliminando" + +#~ msgid "完成" +#~ msgstr "Completado" + +#~ msgid "失败" +#~ msgstr "Fallido" + +#~ msgid "跳过" +#~ msgstr "Omitido" + +#~ msgid "待处理" +#~ msgstr "Pendiente" + +#~ msgid "检查" +#~ msgstr "Comprobar" + +#~ msgid "进度" +#~ msgstr "Progreso" + +#~ msgid "DeleteStack 已提交,等待删除完成({progress})" +#~ msgstr "DeleteStack enviado; esperando que termine la eliminación ({progress})" + +#~ msgid "DeleteStack 已提交,等待删除完成" +#~ msgstr "DeleteStack enviado; esperando que termine la eliminación" + +#~ msgid "已跳过" +#~ msgstr "Omitido" + +#~ msgid "正在删除({progress})" +#~ msgstr "Eliminando ({progress})" + +#~ msgid "{progress},需要删除" +#~ msgstr "{progress}, requiere eliminación" + +#~ msgid "资源栈" +#~ msgstr "Stack de recursos" + +#~ msgid "资源" +#~ msgstr "Recurso" + +#~ msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +#~ msgstr "" +#~ "No se pudo leer el registro de " +#~ "limpieza de rollback; se conservó el " +#~ "prompt de limpieza. Continúe más tarde" +#~ " o revise manualmente." + +#~ msgid "还有 {count} 个需要关注的资源未显示。" +#~ msgstr "Hay {count} recursos más que requieren atención y no se muestran." + +#~ msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +#~ msgstr "" +#~ "↺ Recuperación de limpieza de rollback:" +#~ " las {count} entradas están completadas." + +#~ msgid "进行中" +#~ msgstr "En curso" + +#~ msgid "已完成" +#~ msgstr "Completado" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" +#~ msgstr "↺ Recuperación de limpieza de rollback: {count} entradas, {summary}." + +#~ msgid "↺ 回滚清理恢复:{count} 条记录。" +#~ msgstr "↺ Recuperación de limpieza de rollback: {count} entradas." + +#~ msgid "Note: images are not supported in pipeline mode and will be ignored." +#~ msgstr "" +#~ "Nota: las imágenes no son compatibles" +#~ " con el modo pipeline y se " +#~ "ignorarán." diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 88e73ef8..7b954ac8 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -49,6 +49,14 @@ msgstr "Nom de fichier d’artefact non sûr" msgid "Unknown error" msgstr "Erreur inconnue" +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "" +"Cleanup state unavailable. Inspect the session file and cloud resources " +"manually." +msgstr "" +"État de nettoyage indisponible. Inspectez manuellement le fichier de " +"session et les ressources cloud." + #: src/iac_code/a2a/executor.py msgid "" "Rollback cleanup deferred prompt state is unavailable. Please repair it " @@ -82,6 +90,11 @@ msgstr "La tâche est déjà en cours." msgid "Task canceled." msgstr "Tâche annulée." +#: src/iac_code/a2a/executor.py +#, fuzzy, python-brace-format +msgid "Current model {model} does not support image input." +msgstr "Le modèle {model} ne prend pas en charge l’effort de raisonnement." + #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." msgstr "Une erreur temporaire s’est produite. Veuillez réessayer." @@ -90,6 +103,11 @@ msgstr "Une erreur temporaire s’est produite. Veuillez réessayer." msgid "Authentication required. Configure credentials and retry." msgstr "Authentification requise. Configurez les identifiants puis réessayez." +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "Pipeline already running. Resume task {task_id}." +msgstr "Le pipeline est déjà en cours d'exécution. Reprenez la tâche {task_id}." + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "État du pipeline A2A introuvable" @@ -525,6 +543,22 @@ msgstr "Outil d’orchestration d’infrastructure assisté par IA" msgid "Use iac-code as an A2A client." msgstr "Utilise iac-code comme client A2A." +#: src/iac_code/cli/main.py +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"Les dépendances du client A2A sont manquantes. Installez-les avec : pip " +"install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"Les dépendances du serveur A2A sont manquantes. Installez-les avec : pip " +"install 'iac-code[a2a]'" + #: src/iac_code/cli/main.py msgid "Install Git for Windows via the npmmirror mirror (Windows only)." msgstr "Installer Git for Windows via le miroir npmmirror (Windows uniquement)." @@ -537,14 +571,6 @@ msgstr "Mettre à jour iac-code vers la dernière version." msgid "YAML config file containing A2A client options" msgstr "Fichier de configuration YAML contenant les options client A2A" -#: src/iac_code/cli/main.py -msgid "" -"A2A client dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "" -"Les dépendances du client A2A sont manquantes. Installez-les avec : pip " -"install 'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "LLM model to use" msgstr "Modèle LLM à utiliser" @@ -680,14 +706,6 @@ msgstr "" "Expose les types de signal de thinking A2A ; répétez pour en fournir " "plusieurs. Valeurs : raw-thinking, tool-trace." -#: src/iac_code/cli/main.py -msgid "" -"A2A server dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "" -"Les dépendances du serveur A2A sont manquantes. Installez-les avec : pip " -"install 'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "Send a prompt to an A2A JSON-RPC endpoint." msgstr "Envoie un prompt à un point de terminaison JSON-RPC A2A." @@ -1643,7 +1661,8 @@ msgid "cleanup prompt" msgstr "prompt de nettoyage" #: src/iac_code/commands/prompt.py -msgid "cleanup prompt · 已移除" +#, fuzzy +msgid "cleanup prompt · removed" msgstr "prompt de nettoyage · supprimé" #: src/iac_code/commands/prompt.py @@ -2240,107 +2259,129 @@ msgid "User cancelled ask_user_question." msgstr "L’utilisateur a annulé ask_user_question." #: src/iac_code/pipeline/engine/cleanup.py -msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +msgid "" +"Cloud resources still need cleanup after pipeline rollback. Clean them up" +" now and keep checking until deletion completes." msgstr "" -"Des ressources cloud nécessitant encore un nettoyage après le rollback du" -" pipeline ont été détectées. Nettoyez-les immédiatement et vérifiez " -"jusqu’à la fin de la suppression." +"Des ressources cloud doivent encore être nettoyées après le rollback du " +"pipeline. Nettoyez-les maintenant et continuez à vérifier jusqu'à la fin " +"de la suppression." #: src/iac_code/pipeline/engine/cleanup.py -msgid "要求:" -msgstr "Exigences :" +#, fuzzy +msgid "Requirements:" +msgstr "obligatoire" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +msgid "" +"- Cleanup scope is a strict allowlist: delete only ids in the cleanup " +"resources list below." msgstr "" -"- Le périmètre de nettoyage est une liste blanche stricte : seuls les id " -"listés dans « Ressources à nettoyer » ci-dessous peuvent être supprimés." +"- Le périmètre de nettoyage est une liste autorisée stricte : supprimez " +"uniquement les ID de la liste des ressources de nettoyage ci-dessous." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +msgid "" +"- Do not delete, modify, or roll back any stack or cloud resource outside" +" the cleanup resources list." msgstr "" -"- Ne supprimez, modifiez ni annulez aucun stack ou ressource cloud non " -"listé dans « Ressources à nettoyer »." +"- Ne supprimez, modifiez ou restaurez aucun stack ni aucune ressource " +"cloud hors de la liste des ressources de nettoyage." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +msgid "" +"- Do not call ListStacks or search for other stacks by name; cleanup " +"resource ids are fully listed." msgstr "" -"- N'appelez pas ListStacks et ne recherchez pas d'autres stacks par nom ;" -" les id des ressources a nettoyer sont deja tous listes." +"- N’appelez pas ListStacks et ne cherchez pas d’autres stacks par nom ; " +"les ID des ressources de nettoyage sont entièrement listés." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +msgid "" +"- Before every GetStack/DeleteStack call, verify that StackId exactly " +"matches an id in the cleanup resources list." msgstr "" -"- Avant chaque appel a GetStack/DeleteStack, verifiez que StackId " -"correspond exactement a un id de la liste des ressources a nettoyer." +"- Avant chaque appel GetStack/DeleteStack, vérifiez que StackId " +"correspond exactement à un ID de la liste des ressources de nettoyage." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +msgid "" +"- If StackId is not in the cleanup resources list, do not call " +"DeleteStack, even if it is the current handoff or newly created stack." msgstr "" -"- Si StackId n'est pas dans la liste des ressources a nettoyer, il est " -"interdit d'appeler DeleteStack, meme s'il s'agit du stack du handoff " -"actuel ou d'un stack tout juste cree." +"- Si StackId n’est pas dans la liste des ressources de nettoyage, " +"n’appelez pas DeleteStack, même s’il s’agit du stack courant du handoff " +"ou d’un stack récemment créé." #: src/iac_code/pipeline/engine/cleanup.py msgid "" -"- 不要根据 pipeline handoff、deployment.stack_id、current stack 或 " -"resources_created 额外推断清理对象;这些可能是最终成功交付的资源。" +"- Do not infer extra cleanup targets from pipeline handoff, " +"deployment.stack_id, current stack, or resources_created; those may be " +"final delivered resources." msgstr "" -"- Ne déduisez pas d’autres objets à nettoyer depuis pipeline handoff, " -"deployment.stack_id, current stack ou resources_created ; ils peuvent " -"être les ressources finalement livrées avec succès." +"- N’inférez aucune cible de nettoyage supplémentaire depuis le handoff du" +" pipeline, deployment.stack_id, le stack courant ou resources_created ; " +"il peut s’agir de ressources livrées finales." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +msgid "" +"- Do not expand cleanup scope for user follow-ups, continue instructions," +" or pipeline handoff context." msgstr "" -"- Même s’il existe des questions utilisateur, des instructions de " -"poursuite ou un contexte de pipeline handoff dans ce tour, n’élargissez " -"pas le périmètre de nettoyage." +"- N’élargissez pas le périmètre de nettoyage en raison d’un suivi " +"utilisateur, d’instructions de continuation ou du contexte de handoff du " +"pipeline." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +msgid "" +"- When resuming cleanup, still process only resources listed in this " +"prompt; do not inspect or delete others." msgstr "" -"- Lors de la reprise ou de la poursuite du nettoyage, traitez uniquement " -"les ressources listees dans cette invite ; ne verifiez ni ne supprimez " -"d'autres ressources." +"- Lors de la reprise du nettoyage, traitez uniquement les ressources " +"listées dans ce prompt ; n’inspectez et ne supprimez pas les autres." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 优先使用可用的 ROS stack 工具删除;如果改用 aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +msgid "" +"- Prefer available ROS stack tools for deletion; if using aliyun_api, " +"call DeleteStack first, then repeatedly call GetStack to check status." msgstr "" -"- Utilisez en priorité l’outil ROS stack disponible pour supprimer ; si " -"vous utilisez aliyun_api, lancez d’abord DeleteStack puis vérifiez l’état" -" avec GetStack à plusieurs reprises." +"- Préférez les outils de stack ROS disponibles pour la suppression ; si " +"vous utilisez aliyun_api, appelez d’abord DeleteStack, puis appelez " +"GetStack à plusieurs reprises pour vérifier le statut." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +msgid "" +"- If a resource is already deleting, call GetStack first, then decide " +"whether DeleteStack is needed again." msgstr "" -"- Si la ressource est déjà en cours de suppression, utilisez d’abord " -"GetStack pour vérifier son état actuel avant de décider s’il faut " -"relancer DeleteStack." +"- Si une ressource est déjà en cours de suppression, appelez d’abord " +"GetStack, puis décidez si DeleteStack est à nouveau nécessaire." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +msgid "" +"- Cleanup is complete only after DELETE_COMPLETE; for DELETE_FAILED or " +"unknown status, tell the user the failure reason and next step." msgstr "" -"- Le nettoyage n’est terminé qu’après confirmation de DELETE_COMPLETE ; " -"en cas de DELETE_FAILED ou d’impossibilité de confirmer, expliquez à " -"l’utilisateur la cause de l’échec et la suite." +"- Le nettoyage n’est terminé qu’après DELETE_COMPLETE ; pour " +"DELETE_FAILED ou un statut inconnu, indiquez à l’utilisateur la raison de" +" l’échec et l’étape suivante." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +msgid "" +"- After all listed resources are DELETE_COMPLETE, stop this cleanup turn " +"immediately." msgstr "" -"- Lorsque toutes les ressources de la liste sont en DELETE_COMPLETE, " -"arrêtez immédiatement ce nettoyage ; ne supprimez ni ne vérifiez aucun " -"autre stack." +"- Une fois toutes les ressources listées en DELETE_COMPLETE, arrêtez " +"immédiatement ce tour de nettoyage." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理过程中向用户简短同步进度。" -msgstr "" -"- Tenez brièvement l’utilisateur informé de la progression pendant le " -"nettoyage." +msgid "- Briefly update the user during cleanup." +msgstr "- Tenez brièvement l’utilisateur informé pendant le nettoyage." #: src/iac_code/pipeline/engine/cleanup.py -msgid "待清理资源:" -msgstr "Ressources à nettoyer :" +#, fuzzy +msgid "Cleanup resources:" +msgstr "Prompts de nettoyage" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2353,10 +2394,10 @@ msgstr "" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format -msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +msgid "Detected {count} rollback cleanup resources; starting cleanup." msgstr "" -"{count} ressources résiduelles du rollback détectées ; démarrage du flux " -"de nettoyage." +"{count} ressources de nettoyage du rollback détectées ; démarrage du " +"nettoyage." #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" @@ -2471,7 +2512,7 @@ msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" -msgstr "" +msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2554,6 +2595,11 @@ msgstr "" msgid "Step {step_id} completed. Conclusion submitted." msgstr "Étape {step_id} terminée. Conclusion soumise." +#: src/iac_code/pipeline/engine/pipeline_runner.py +#, fuzzy +msgid "Pipeline state persistence failed." +msgstr "État du pipeline A2A introuvable" + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -2654,6 +2700,10 @@ msgstr "Le chemin du fichier de modèle doit être relatif au répertoire de tra msgid "Template file path cannot escape the working directory" msgstr "Le chemin du fichier de modèle ne peut pas sortir du répertoire de travail" +#: src/iac_code/pipeline/engine/user_input.py +msgid "[Image input]" +msgstr "[Entrée d'image]" + #: src/iac_code/pipeline/selling/tools/show_candidate_detail_tool.py msgid "" "Display candidate details (summary and cost breakdown) in the comparison " @@ -3673,11 +3723,11 @@ msgstr "Interrompu" msgid "Running" msgstr "En cours" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Completed" msgstr "Terminé" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Failed" msgstr "Échec" @@ -3958,8 +4008,8 @@ msgid "Command has no handler: {name}" msgstr "Aucun gestionnaire pour la commande : {name}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "↺ 回滚清理 [{badge}] {label}" +#, fuzzy, python-brace-format +msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ Nettoyage du rollback [{badge}] {label}" #: src/iac_code/ui/repl.py @@ -3968,108 +4018,120 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "错误:{error}" -msgstr "Erreur : {error}" - -#: src/iac_code/ui/repl.py -msgid "删除中" -msgstr "Suppression" - -#: src/iac_code/ui/repl.py -msgid "完成" -msgstr "Terminé" - -#: src/iac_code/ui/repl.py -msgid "失败" -msgstr "Échec" +#, fuzzy +msgid "Deleting" +msgstr "Déploiement" #: src/iac_code/ui/repl.py -msgid "跳过" -msgstr "Ignoré" +#, fuzzy +msgid "Skipped" +msgstr "Ignorer" #: src/iac_code/ui/repl.py -msgid "待处理" -msgstr "En attente" +#, fuzzy +msgid "Pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -msgid "检查" -msgstr "Vérifier" +#, fuzzy +msgid "Checking" +msgstr "VÉRIFICATION EN COURS" #: src/iac_code/ui/repl.py -msgid "进度" -msgstr "Progression" +#, fuzzy +msgid "Progress" +msgstr "Traité" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "DeleteStack 已提交,等待删除完成({progress})" +msgid "DeleteStack submitted; waiting for deletion to complete ({progress})" msgstr "DeleteStack envoyé ; attente de la fin de la suppression ({progress})" #: src/iac_code/ui/repl.py -msgid "DeleteStack 已提交,等待删除完成" +msgid "DeleteStack submitted; waiting for deletion to complete" msgstr "DeleteStack envoyé ; attente de la fin de la suppression" -#: src/iac_code/ui/repl.py -msgid "已跳过" -msgstr "Ignoré" - #: src/iac_code/ui/repl.py #, python-brace-format -msgid "正在删除({progress})" -msgstr "Suppression ({progress})" +msgid "Deleting ({progress})" +msgstr "Suppression en cours ({progress})" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "{progress},需要删除" -msgstr "{progress}, suppression requise" +msgid "{progress}; deletion required" +msgstr "{progress} ; suppression requise" #: src/iac_code/ui/repl.py -msgid "资源栈" -msgstr "Stack de ressources" +#, fuzzy +msgid "stack" +msgstr "ROS Stack" #: src/iac_code/ui/repl.py -msgid "资源" +#, fuzzy +msgid "resource" msgstr "Ressource" #: src/iac_code/ui/repl.py -msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +msgid "" +"Could not read rollback cleanup records. The cleanup prompt was kept; " +"retry later or inspect manually." msgstr "" -"Impossible de lire le journal de nettoyage du rollback ; le prompt de " -"nettoyage a été conservé. Continuez plus tard ou vérifiez manuellement." +"Impossible de lire les enregistrements de nettoyage du rollback. Le " +"prompt de nettoyage a été conservé ; réessayez plus tard ou inspectez " +"manuellement." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "还有 {count} 个需要关注的资源未显示。" -msgstr "{count} autres ressources nécessitant une attention ne sont pas affichées." +msgid "{count} additional resources needing attention were not shown." +msgstr "" +"{count} ressources supplémentaires nécessitant une attention n'ont pas " +"été affichées." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +msgid "↺ Rollback cleanup resume: all {count} records are completed." msgstr "" "↺ Reprise du nettoyage du rollback : les {count} enregistrements sont " "terminés." #: src/iac_code/ui/repl.py -msgid "进行中" -msgstr "En cours" +#, fuzzy +msgid "failed" +msgstr "Échec" #: src/iac_code/ui/repl.py -msgid "已完成" +#, fuzzy +msgid "pending" +msgstr "OpenAI" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "in progress" +msgstr "VÉRIFICATION EN COURS" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "completed" msgstr "Terminé" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "{count} 条{label}" +#, fuzzy +msgid "skipped" +msgstr "Ignorer" + +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "{count} {label}" msgstr "{count} {label}" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" +msgid "↺ Rollback cleanup resume: {count} records, {summary}." msgstr "↺ Reprise du nettoyage du rollback : {count} enregistrements, {summary}." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录。" +msgid "↺ Rollback cleanup resume: {count} records." msgstr "↺ Reprise du nettoyage du rollback : {count} enregistrements." #: src/iac_code/ui/repl.py @@ -4083,12 +4145,6 @@ msgstr "" "Des ressources de nettoyage du rollback ont été détectées, mais " "l’injection du prompt de nettoyage a échoué." -#: src/iac_code/ui/repl.py -msgid "Note: images are not supported in pipeline mode and will be ignored." -msgstr "" -"Remarque : les images ne sont pas prises en charge en mode pipeline et " -"seront ignorées." - #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -4096,8 +4152,25 @@ msgstr "État de pipeline enregistré ignoré : {reason}" #: src/iac_code/ui/repl.py msgid "" -"Pipeline completed. Normal chat is active, but the handoff context could " -"not be injected or saved." +"Pipeline state persistence failed. The pipeline is paused; do not " +"continue until state is durable." +msgstr "" +"Échec de la persistance de l'état du pipeline. Le pipeline est en pause ;" +" ne continuez pas tant que l'état n'est pas durable." + +#: src/iac_code/ui/repl.py +msgid "" +"Pipeline state persistence failed. Normal chat handoff was not marked " +"durable." +msgstr "" +"Échec de la persistance de l'état du pipeline. Le handoff vers le chat " +"normal n'a pas été marqué comme durable." + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "" +"Pipeline completed, but the handoff context could not be injected or " +"saved." msgstr "" "Le pipeline est terminé. Le chat normal est actif, mais le contexte de " "transfert n'a pas pu être injecté ou enregistré." @@ -4361,6 +4434,16 @@ msgstr "erreur inconnue" msgid "Resumed pipeline at step: {step}" msgstr "Pipeline repris à l'étape : {step}" +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "Could not read pipeline state metadata: {reason}" +msgstr "Impossible de reprendre l'état du pipeline : {reason}" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "Pipeline state metadata is invalid; continuing as normal chat." +msgstr "Ignorer l'état du pipeline et continuer comme un chat normal" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Found pipeline state in this session (paused at: {step})." @@ -4908,3 +4991,210 @@ msgstr "Trace de pile omise de l’événement public ; consultez error_id." #~ msgid "Project Memory Index" #~ msgstr "Index de mémoire du projet" + +#~ msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +#~ msgstr "" +#~ "Des ressources cloud nécessitant encore " +#~ "un nettoyage après le rollback du " +#~ "pipeline ont été détectées. Nettoyez-les" +#~ " immédiatement et vérifiez jusqu’à la " +#~ "fin de la suppression." + +#~ msgid "要求:" +#~ msgstr "Exigences :" + +#~ msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +#~ msgstr "" +#~ "- Le périmètre de nettoyage est " +#~ "une liste blanche stricte : seuls " +#~ "les id listés dans « Ressources à" +#~ " nettoyer » ci-dessous peuvent être" +#~ " supprimés." + +#~ msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +#~ msgstr "" +#~ "- Ne supprimez, modifiez ni annulez " +#~ "aucun stack ou ressource cloud non " +#~ "listé dans « Ressources à nettoyer " +#~ "»." + +#~ msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +#~ msgstr "" +#~ "- N'appelez pas ListStacks et ne " +#~ "recherchez pas d'autres stacks par nom" +#~ " ; les id des ressources a " +#~ "nettoyer sont deja tous listes." + +#~ msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +#~ msgstr "" +#~ "- Avant chaque appel a " +#~ "GetStack/DeleteStack, verifiez que StackId " +#~ "correspond exactement a un id de " +#~ "la liste des ressources a nettoyer." + +#~ msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +#~ msgstr "" +#~ "- Si StackId n'est pas dans la " +#~ "liste des ressources a nettoyer, il " +#~ "est interdit d'appeler DeleteStack, meme " +#~ "s'il s'agit du stack du handoff " +#~ "actuel ou d'un stack tout juste " +#~ "cree." + +#~ msgid "" +#~ "- 不要根据 pipeline handoff、deployment.stack_id、current" +#~ " stack 或 resources_created " +#~ "额外推断清理对象;这些可能是最终成功交付的资源。" +#~ msgstr "" +#~ "- Ne déduisez pas d’autres objets " +#~ "à nettoyer depuis pipeline handoff, " +#~ "deployment.stack_id, current stack ou " +#~ "resources_created ; ils peuvent être les" +#~ " ressources finalement livrées avec succès." + +#~ msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +#~ msgstr "" +#~ "- Même s’il existe des questions " +#~ "utilisateur, des instructions de poursuite " +#~ "ou un contexte de pipeline handoff " +#~ "dans ce tour, n’élargissez pas le " +#~ "périmètre de nettoyage." + +#~ msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +#~ msgstr "" +#~ "- Lors de la reprise ou de " +#~ "la poursuite du nettoyage, traitez " +#~ "uniquement les ressources listees dans " +#~ "cette invite ; ne verifiez ni ne" +#~ " supprimez d'autres ressources." + +#~ msgid "" +#~ "- 优先使用可用的 ROS stack 工具删除;如果改用 " +#~ "aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +#~ msgstr "" +#~ "- Utilisez en priorité l’outil ROS " +#~ "stack disponible pour supprimer ; si " +#~ "vous utilisez aliyun_api, lancez d’abord " +#~ "DeleteStack puis vérifiez l’état avec " +#~ "GetStack à plusieurs reprises." + +#~ msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +#~ msgstr "" +#~ "- Si la ressource est déjà en " +#~ "cours de suppression, utilisez d’abord " +#~ "GetStack pour vérifier son état actuel" +#~ " avant de décider s’il faut relancer" +#~ " DeleteStack." + +#~ msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +#~ msgstr "" +#~ "- Le nettoyage n’est terminé qu’après" +#~ " confirmation de DELETE_COMPLETE ; en " +#~ "cas de DELETE_FAILED ou d’impossibilité " +#~ "de confirmer, expliquez à l’utilisateur " +#~ "la cause de l’échec et la suite." + +#~ msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +#~ msgstr "" +#~ "- Lorsque toutes les ressources de " +#~ "la liste sont en DELETE_COMPLETE, " +#~ "arrêtez immédiatement ce nettoyage ; ne" +#~ " supprimez ni ne vérifiez aucun autre" +#~ " stack." + +#~ msgid "- 清理过程中向用户简短同步进度。" +#~ msgstr "" +#~ "- Tenez brièvement l’utilisateur informé " +#~ "de la progression pendant le nettoyage." + +#~ msgid "待清理资源:" +#~ msgstr "Ressources à nettoyer :" + +#~ msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +#~ msgstr "" +#~ "{count} ressources résiduelles du rollback " +#~ "détectées ; démarrage du flux de " +#~ "nettoyage." + +#~ msgid "错误:{error}" +#~ msgstr "Erreur : {error}" + +#~ msgid "删除中" +#~ msgstr "Suppression" + +#~ msgid "完成" +#~ msgstr "Terminé" + +#~ msgid "失败" +#~ msgstr "Échec" + +#~ msgid "跳过" +#~ msgstr "Ignoré" + +#~ msgid "待处理" +#~ msgstr "En attente" + +#~ msgid "检查" +#~ msgstr "Vérifier" + +#~ msgid "进度" +#~ msgstr "Progression" + +#~ msgid "DeleteStack 已提交,等待删除完成({progress})" +#~ msgstr "DeleteStack envoyé ; attente de la fin de la suppression ({progress})" + +#~ msgid "DeleteStack 已提交,等待删除完成" +#~ msgstr "DeleteStack envoyé ; attente de la fin de la suppression" + +#~ msgid "已跳过" +#~ msgstr "Ignoré" + +#~ msgid "正在删除({progress})" +#~ msgstr "Suppression ({progress})" + +#~ msgid "{progress},需要删除" +#~ msgstr "{progress}, suppression requise" + +#~ msgid "资源栈" +#~ msgstr "Stack de ressources" + +#~ msgid "资源" +#~ msgstr "Ressource" + +#~ msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +#~ msgstr "" +#~ "Impossible de lire le journal de " +#~ "nettoyage du rollback ; le prompt " +#~ "de nettoyage a été conservé. Continuez" +#~ " plus tard ou vérifiez manuellement." + +#~ msgid "还有 {count} 个需要关注的资源未显示。" +#~ msgstr "" +#~ "{count} autres ressources nécessitant une " +#~ "attention ne sont pas affichées." + +#~ msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +#~ msgstr "" +#~ "↺ Reprise du nettoyage du rollback " +#~ ": les {count} enregistrements sont " +#~ "terminés." + +#~ msgid "进行中" +#~ msgstr "En cours" + +#~ msgid "已完成" +#~ msgstr "Terminé" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" +#~ msgstr "" +#~ "↺ Reprise du nettoyage du rollback " +#~ ": {count} enregistrements, {summary}." + +#~ msgid "↺ 回滚清理恢复:{count} 条记录。" +#~ msgstr "↺ Reprise du nettoyage du rollback : {count} enregistrements." + +#~ msgid "Note: images are not supported in pipeline mode and will be ignored." +#~ msgstr "" +#~ "Remarque : les images ne sont pas" +#~ " prises en charge en mode pipeline" +#~ " et seront ignorées." diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 3d421449..0cfaf096 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -44,6 +44,12 @@ msgstr "安全でないアーティファクトファイル名" msgid "Unknown error" msgstr "不明なエラー" +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "" +"Cleanup state unavailable. Inspect the session file and cloud resources " +"manually." +msgstr "クリーンアップ状態を利用できません。セッションファイルとクラウドリソースを手動で確認してください。" + #: src/iac_code/a2a/executor.py msgid "" "Rollback cleanup deferred prompt state is unavailable. Please repair it " @@ -71,6 +77,11 @@ msgstr "タスクはすでに実行中です。" msgid "Task canceled." msgstr "タスクがキャンセルされました。" +#: src/iac_code/a2a/executor.py +#, fuzzy, python-brace-format +msgid "Current model {model} does not support image input." +msgstr "モデル {model} は effort(思考の負荷)をサポートしていません。" + #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." msgstr "一時的なエラーが発生しました。再試行してください。" @@ -79,6 +90,11 @@ msgstr "一時的なエラーが発生しました。再試行してください msgid "Authentication required. Configure credentials and retry." msgstr "認証が必要です。認証情報を設定して再試行してください。" +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "Pipeline already running. Resume task {task_id}." +msgstr "パイプラインはすでに実行中です。タスク {task_id} を再開してください。" + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "A2A パイプライン状態が見つかりません" @@ -497,6 +513,18 @@ msgstr "AI 駆動のインフラストラクチャ・オーケストレーショ msgid "Use iac-code as an A2A client." msgstr "iac-code を A2A クライアントとして使用します。" +#: src/iac_code/cli/main.py +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "A2A クライアントの依存関係が不足しています。次のコマンドでインストールしてください: pip install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "A2A サーバーの依存関係が不足しています。次のコマンドでインストールしてください: pip install 'iac-code[a2a]'" + #: src/iac_code/cli/main.py msgid "Install Git for Windows via the npmmirror mirror (Windows only)." msgstr "npmmirror ミラー経由で Git for Windows をインストールします(Windows 専用)。" @@ -509,12 +537,6 @@ msgstr "iac-code を最新バージョンに更新します。" msgid "YAML config file containing A2A client options" msgstr "A2A クライアントオプションを含む YAML 設定ファイル" -#: src/iac_code/cli/main.py -msgid "" -"A2A client dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "A2A クライアントの依存関係が不足しています。次のコマンドでインストールしてください: pip install 'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "LLM model to use" msgstr "使用する LLM モデル" @@ -640,12 +662,6 @@ msgid "" "thinking, tool-trace." msgstr "A2A thinking 信号タイプを公開します。複数指定するには繰り返します。値:raw-thinking、tool-trace。" -#: src/iac_code/cli/main.py -msgid "" -"A2A server dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "A2A サーバーの依存関係が不足しています。次のコマンドでインストールしてください: pip install 'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "Send a prompt to an A2A JSON-RPC endpoint." msgstr "A2A JSON-RPC エンドポイントにプロンプトを送信します。" @@ -1595,7 +1611,8 @@ msgid "cleanup prompt" msgstr "クリーンアッププロンプト" #: src/iac_code/commands/prompt.py -msgid "cleanup prompt · 已移除" +#, fuzzy +msgid "cleanup prompt · removed" msgstr "クリーンアッププロンプト · 削除済み" #: src/iac_code/commands/prompt.py @@ -2165,88 +2182,107 @@ msgid "User cancelled ask_user_question." msgstr "ユーザーが ask_user_question をキャンセルしました。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" -msgstr "" -"pipeline rollback " -"後もクリーンアップが必要なクラウドリソースを検出しました。直ちにこれらを削除し、削除完了まで継続して確認してください。" +msgid "" +"Cloud resources still need cleanup after pipeline rollback. Clean them up" +" now and keep checking until deletion completes." +msgstr "パイプラインのロールバック後もクラウドリソースのクリーンアップが必要です。今すぐクリーンアップし、削除が完了するまで確認を続けてください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "要求:" -msgstr "要件:" +#, fuzzy +msgid "Requirements:" +msgstr "必須" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" -msgstr "- クリーンアップ範囲は厳密なホワイトリストです。以下の「クリーンアップ対象リソース」一覧の id だけを削除できます。" +msgid "" +"- Cleanup scope is a strict allowlist: delete only ids in the cleanup " +"resources list below." +msgstr "- クリーンアップ範囲は厳密な許可リストです。下のクリーンアップリソース一覧にある ID だけを削除してください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" -msgstr "- 「クリーンアップ対象リソース」に記載されていない stack やクラウドリソースは削除、変更、ロールバックしないでください。" +msgid "" +"- Do not delete, modify, or roll back any stack or cloud resource outside" +" the cleanup resources list." +msgstr "- クリーンアップリソース一覧にないスタックやクラウドリソースを削除、変更、ロールバックしないでください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" -msgstr "" -"- ListStacks を呼び出したり、名前で他の stack を検索したりしないでください。クリーンアップ対象リソースの id " -"はすべて列挙されています。" +msgid "" +"- Do not call ListStacks or search for other stacks by name; cleanup " +"resource ids are fully listed." +msgstr "- ListStacks を呼び出したり、名前で他のスタックを検索したりしないでください。クリーンアップリソース ID は完全に列挙されています。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +msgid "" +"- Before every GetStack/DeleteStack call, verify that StackId exactly " +"matches an id in the cleanup resources list." msgstr "" -"- GetStack/DeleteStack を呼び出す前に、StackId が「クリーンアップ対象リソース」リスト内のいずれかの id " -"と完全に一致することを必ず確認してください。" +"- GetStack/DeleteStack を呼び出す前に、StackId がクリーンアップリソース一覧内の ID " +"と完全に一致することを確認してください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +msgid "" +"- If StackId is not in the cleanup resources list, do not call " +"DeleteStack, even if it is the current handoff or newly created stack." msgstr "" -"- StackId が「クリーンアップ対象リソース」リストにない場合、それが現在の handoff や作成直後の stack であっても " -"DeleteStack を呼び出してはいけません。" +"- StackId がクリーンアップリソース一覧にない場合は、それが現在の handoff や新規作成スタックでも DeleteStack " +"を呼び出さないでください。" #: src/iac_code/pipeline/engine/cleanup.py msgid "" -"- 不要根据 pipeline handoff、deployment.stack_id、current stack 或 " -"resources_created 额外推断清理对象;这些可能是最终成功交付的资源。" +"- Do not infer extra cleanup targets from pipeline handoff, " +"deployment.stack_id, current stack, or resources_created; those may be " +"final delivered resources." msgstr "" -"- pipeline handoff、deployment.stack_id、current stack、resources_created " -"から追加のクリーンアップ対象を推測しないでください。これらは最終的に正常提供されたリソースかもしれません。" +"- pipeline handoff、deployment.stack_id、現在のスタック、resources_created " +"から追加のクリーンアップ対象を推測しないでください。それらは最終納品リソースの可能性があります。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" -msgstr "- このラウンドにユーザーの追加質問、続行指示、pipeline handoff コンテキストがあっても、クリーンアップ範囲を広げないでください。" +msgid "" +"- Do not expand cleanup scope for user follow-ups, continue instructions," +" or pipeline handoff context." +msgstr "- ユーザーの追加質問、continue 指示、pipeline handoff コンテキストを理由にクリーンアップ範囲を広げないでください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" -msgstr "" -"- " -"クリーンアップを再開または続行する場合も、このプロンプトに列挙されたリソースだけを処理してください。他のリソースを確認または削除しないでください。" +msgid "" +"- When resuming cleanup, still process only resources listed in this " +"prompt; do not inspect or delete others." +msgstr "- クリーンアップを再開するときも、このプロンプトに列挙されたリソースだけを処理し、それ以外を調査または削除しないでください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 优先使用可用的 ROS stack 工具删除;如果改用 aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +msgid "" +"- Prefer available ROS stack tools for deletion; if using aliyun_api, " +"call DeleteStack first, then repeatedly call GetStack to check status." msgstr "" -"- 利用可能な ROS stack ツールでの削除を優先してください。aliyun_api を使う場合は、まず DeleteStack " -"を実行し、その後 GetStack で状態を繰り返し確認してください。" +"- 削除には利用可能な ROS スタックツールを優先してください。aliyun_api を使う場合は、最初に DeleteStack " +"を呼び出し、その後 GetStack を繰り返し呼び出して状態を確認してください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" -msgstr "- リソースがすでに削除中の場合は、まず GetStack で現在状態を確認し、DeleteStack を再実行する必要があるか判断してください。" +msgid "" +"- If a resource is already deleting, call GetStack first, then decide " +"whether DeleteStack is needed again." +msgstr "- リソースがすでに削除中の場合は、まず GetStack を呼び出し、その後 DeleteStack が再度必要か判断してください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +msgid "" +"- Cleanup is complete only after DELETE_COMPLETE; for DELETE_FAILED or " +"unknown status, tell the user the failure reason and next step." msgstr "" -"- DELETE_COMPLETE を確認した場合のみクリーンアップ完了です。DELETE_FAILED " -"または確認不能の場合は、失敗原因と次の手順をユーザーに説明してください。" +"- クリーンアップは DELETE_COMPLETE 後にのみ完了です。DELETE_FAILED " +"または不明な状態の場合は、失敗理由と次の手順をユーザーに伝えてください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" -msgstr "" -"- 一覧内のすべてのリソースが DELETE_COMPLETE になったら、このクリーンアップを直ちに停止してください。他の stack " -"を削除または確認しないでください。" +msgid "" +"- After all listed resources are DELETE_COMPLETE, stop this cleanup turn " +"immediately." +msgstr "- 列挙されたすべてのリソースが DELETE_COMPLETE になったら、このクリーンアップターンを直ちに停止してください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理过程中向用户简短同步进度。" -msgstr "- クリーンアップ中は進捗を簡潔にユーザーへ共有してください。" +msgid "- Briefly update the user during cleanup." +msgstr "- クリーンアップ中はユーザーに簡潔に進捗を伝えてください。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "待清理资源:" -msgstr "クリーンアップ対象リソース:" +#, fuzzy +msgid "Cleanup resources:" +msgstr "クリーンアッププロンプト" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2254,13 +2290,13 @@ msgid "" "{index}. provider={provider}, type={resource_type}, id={resource_id}, " "name={name}, region={region}" msgstr "" -"{index}. provider={provider}, type={resource_type}, id={resource_id}, " -"name={name}, region={region}" +"{index}. プロバイダー={provider}, 種別={resource_type}, ID={resource_id}, " +"名前={name}, リージョン={region}" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format -msgid "检测到 {count} 个回滚残留资源,开始清理流程。" -msgstr "{count} 件のロールバック残留リソースを検出しました。クリーンアップフローを開始します。" +msgid "Detected {count} rollback cleanup resources; starting cleanup." +msgstr "{count} 件のロールバッククリーンアップリソースを検出しました。クリーンアップを開始します。" #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" @@ -2365,7 +2401,7 @@ msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" -msgstr "" +msgstr "<欠落>" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2439,6 +2475,11 @@ msgstr "conclusion の検証に失敗しました。修正して complete_step msgid "Step {step_id} completed. Conclusion submitted." msgstr "ステップ {step_id} が完了しました。結論を送信しました。" +#: src/iac_code/pipeline/engine/pipeline_runner.py +#, fuzzy +msgid "Pipeline state persistence failed." +msgstr "A2A パイプライン状態が見つかりません" + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -2534,6 +2575,10 @@ msgstr "テンプレートファイルのパスは作業ディレクトリから msgid "Template file path cannot escape the working directory" msgstr "テンプレートファイルのパスは作業ディレクトリの外に出られません" +#: src/iac_code/pipeline/engine/user_input.py +msgid "[Image input]" +msgstr "[画像入力]" + #: src/iac_code/pipeline/selling/tools/show_candidate_detail_tool.py msgid "" "Display candidate details (summary and cost breakdown) in the comparison " @@ -3513,11 +3558,11 @@ msgstr "中断済み" msgid "Running" msgstr "実行中" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Completed" msgstr "完了" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Failed" msgstr "失敗" @@ -3790,8 +3835,8 @@ msgid "Command has no handler: {name}" msgstr "ハンドラーがないコマンドです:{name}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "↺ 回滚清理 [{badge}] {label}" +#, fuzzy, python-brace-format +msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ ロールバッククリーンアップ [{badge}] {label}" #: src/iac_code/ui/repl.py @@ -3800,105 +3845,114 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "错误:{error}" -msgstr "エラー: {error}" +#, fuzzy +msgid "Deleting" +msgstr "デプロイ" #: src/iac_code/ui/repl.py -msgid "删除中" -msgstr "削除中" +#, fuzzy +msgid "Skipped" +msgstr "スキップ" #: src/iac_code/ui/repl.py -msgid "完成" -msgstr "完了" +#, fuzzy +msgid "Pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -msgid "失败" -msgstr "失敗" +#, fuzzy +msgid "Checking" +msgstr "チェック進行中" #: src/iac_code/ui/repl.py -msgid "跳过" -msgstr "スキップ" +#, fuzzy +msgid "Progress" +msgstr "処理しました" #: src/iac_code/ui/repl.py -msgid "待处理" -msgstr "保留中" +#, python-brace-format +msgid "DeleteStack submitted; waiting for deletion to complete ({progress})" +msgstr "DeleteStack を送信しました。削除完了を待機しています ({progress})" #: src/iac_code/ui/repl.py -msgid "检查" -msgstr "確認" +msgid "DeleteStack submitted; waiting for deletion to complete" +msgstr "DeleteStack を送信しました。削除完了を待機しています" #: src/iac_code/ui/repl.py -msgid "进度" -msgstr "進捗" +#, python-brace-format +msgid "Deleting ({progress})" +msgstr "削除中 ({progress})" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "DeleteStack 已提交,等待删除完成({progress})" -msgstr "DeleteStack を送信しました。削除完了を待機中({progress})" +msgid "{progress}; deletion required" +msgstr "{progress}; 削除が必要です" #: src/iac_code/ui/repl.py -msgid "DeleteStack 已提交,等待删除完成" -msgstr "DeleteStack を送信しました。削除完了を待機中" +#, fuzzy +msgid "stack" +msgstr "ROS スタック" #: src/iac_code/ui/repl.py -msgid "已跳过" -msgstr "スキップ済み" +#, fuzzy +msgid "resource" +msgstr "リソース" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "正在删除({progress})" -msgstr "削除中({progress})" +msgid "" +"Could not read rollback cleanup records. The cleanup prompt was kept; " +"retry later or inspect manually." +msgstr "ロールバッククリーンアップレコードを読み取れませんでした。クリーンアッププロンプトは保持されています。後で再試行するか、手動で確認してください。" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "{progress},需要删除" -msgstr "{progress}、削除が必要" +msgid "{count} additional resources needing attention were not shown." +msgstr "対応が必要な追加リソース {count} 件は表示されませんでした。" #: src/iac_code/ui/repl.py -msgid "资源栈" -msgstr "リソーススタック" - -#: src/iac_code/ui/repl.py -msgid "资源" -msgstr "リソース" +#, python-brace-format +msgid "↺ Rollback cleanup resume: all {count} records are completed." +msgstr "↺ ロールバッククリーンアップ再開: {count} 件のレコードはすべて完了しています。" #: src/iac_code/ui/repl.py -msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" -msgstr "ロールバッククリーンアップ記録を読み取れませんでした。クリーンアッププロンプトは保持されています。後で続行するか手動で確認してください。" +#, fuzzy +msgid "failed" +msgstr "失敗" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "还有 {count} 个需要关注的资源未显示。" -msgstr "注意が必要な未表示のリソースがさらに {count} 件あります。" +#, fuzzy +msgid "pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" -msgstr "↺ ロールバッククリーンアップ復元: {count} 件の記録はすべて完了済みです。" +#, fuzzy +msgid "in progress" +msgstr "チェック進行中" #: src/iac_code/ui/repl.py -msgid "进行中" -msgstr "進行中" +#, fuzzy +msgid "completed" +msgstr "完了" #: src/iac_code/ui/repl.py -msgid "已完成" -msgstr "完了済み" +#, fuzzy +msgid "skipped" +msgstr "スキップ" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "{count} 条{label}" +#, fuzzy, python-brace-format +msgid "{count} {label}" msgstr "{label} {count} 件" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" -msgstr "↺ ロールバッククリーンアップ復元: {count} 件の記録、{summary}。" +msgid "↺ Rollback cleanup resume: {count} records, {summary}." +msgstr "↺ ロールバッククリーンアップ再開: {count} 件のレコード、{summary}。" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录。" -msgstr "↺ ロールバッククリーンアップ復元: {count} 件の記録。" +msgid "↺ Rollback cleanup resume: {count} records." +msgstr "↺ ロールバッククリーンアップ再開: {count} 件のレコード。" #: src/iac_code/ui/repl.py #, python-brace-format @@ -3909,10 +3963,6 @@ msgstr " [{badge}] {label}" msgid "Detected rollback cleanup resources, but cleanup prompt injection failed." msgstr "ロールバッククリーンアップリソースを検出しましたが、クリーンアッププロンプトの注入に失敗しました。" -#: src/iac_code/ui/repl.py -msgid "Note: images are not supported in pipeline mode and will be ignored." -msgstr "注: パイプラインモードでは画像はサポートされておらず、無視されます。" - #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -3920,8 +3970,21 @@ msgstr "保存済みのパイプライン状態を無視しています: {reason #: src/iac_code/ui/repl.py msgid "" -"Pipeline completed. Normal chat is active, but the handoff context could " -"not be injected or saved." +"Pipeline state persistence failed. The pipeline is paused; do not " +"continue until state is durable." +msgstr "パイプライン状態の永続化に失敗しました。パイプラインは一時停止中です。状態が永続化されるまで続行しないでください。" + +#: src/iac_code/ui/repl.py +msgid "" +"Pipeline state persistence failed. Normal chat handoff was not marked " +"durable." +msgstr "パイプライン状態の永続化に失敗しました。通常チャットへのハンドオフは永続化済みとしてマークされていません。" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "" +"Pipeline completed, but the handoff context could not be injected or " +"saved." msgstr "パイプラインが完了しました。通常チャットは有効ですが、引き継ぎコンテキストを注入または保存できませんでした。" #: src/iac_code/ui/repl.py @@ -4167,6 +4230,16 @@ msgstr "不明なエラー" msgid "Resumed pipeline at step: {step}" msgstr "パイプラインをステップ {step} から再開しました" +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "Could not read pipeline state metadata: {reason}" +msgstr "パイプライン状態を再開できませんでした: {reason}" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "Pipeline state metadata is invalid; continuing as normal chat." +msgstr "パイプライン状態を破棄して通常のチャットとして続行" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Found pipeline state in this session (paused at: {step})." @@ -4707,3 +4780,153 @@ msgstr "公開イベントではスタックトレースを省略しました。 #~ msgid "Project Memory Index" #~ msgstr "プロジェクトメモリ索引" + +#~ msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +#~ msgstr "" +#~ "pipeline rollback " +#~ "後もクリーンアップが必要なクラウドリソースを検出しました。直ちにこれらを削除し、削除完了まで継続して確認してください。" + +#~ msgid "要求:" +#~ msgstr "要件:" + +#~ msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +#~ msgstr "- クリーンアップ範囲は厳密なホワイトリストです。以下の「クリーンアップ対象リソース」一覧の id だけを削除できます。" + +#~ msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +#~ msgstr "- 「クリーンアップ対象リソース」に記載されていない stack やクラウドリソースは削除、変更、ロールバックしないでください。" + +#~ msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +#~ msgstr "" +#~ "- ListStacks を呼び出したり、名前で他の stack " +#~ "を検索したりしないでください。クリーンアップ対象リソースの id はすべて列挙されています。" + +#~ msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +#~ msgstr "" +#~ "- GetStack/DeleteStack を呼び出す前に、StackId " +#~ "が「クリーンアップ対象リソース」リスト内のいずれかの id と完全に一致することを必ず確認してください。" + +#~ msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +#~ msgstr "" +#~ "- StackId が「クリーンアップ対象リソース」リストにない場合、それが現在の handoff" +#~ " や作成直後の stack であっても DeleteStack " +#~ "を呼び出してはいけません。" + +#~ msgid "" +#~ "- 不要根据 pipeline handoff、deployment.stack_id、current" +#~ " stack 或 resources_created " +#~ "额外推断清理对象;这些可能是最终成功交付的资源。" +#~ msgstr "" +#~ "- pipeline handoff、deployment.stack_id、current " +#~ "stack、resources_created " +#~ "から追加のクリーンアップ対象を推測しないでください。これらは最終的に正常提供されたリソースかもしれません。" + +#~ msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +#~ msgstr "" +#~ "- このラウンドにユーザーの追加質問、続行指示、pipeline handoff " +#~ "コンテキストがあっても、クリーンアップ範囲を広げないでください。" + +#~ msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +#~ msgstr "" +#~ "- " +#~ "クリーンアップを再開または続行する場合も、このプロンプトに列挙されたリソースだけを処理してください。他のリソースを確認または削除しないでください。" + +#~ msgid "" +#~ "- 优先使用可用的 ROS stack 工具删除;如果改用 " +#~ "aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +#~ msgstr "" +#~ "- 利用可能な ROS stack ツールでの削除を優先してください。aliyun_api" +#~ " を使う場合は、まず DeleteStack を実行し、その後 GetStack " +#~ "で状態を繰り返し確認してください。" + +#~ msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +#~ msgstr "" +#~ "- リソースがすでに削除中の場合は、まず GetStack で現在状態を確認し、DeleteStack" +#~ " を再実行する必要があるか判断してください。" + +#~ msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +#~ msgstr "" +#~ "- DELETE_COMPLETE を確認した場合のみクリーンアップ完了です。DELETE_FAILED " +#~ "または確認不能の場合は、失敗原因と次の手順をユーザーに説明してください。" + +#~ msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +#~ msgstr "" +#~ "- 一覧内のすべてのリソースが DELETE_COMPLETE " +#~ "になったら、このクリーンアップを直ちに停止してください。他の stack を削除または確認しないでください。" + +#~ msgid "- 清理过程中向用户简短同步进度。" +#~ msgstr "- クリーンアップ中は進捗を簡潔にユーザーへ共有してください。" + +#~ msgid "待清理资源:" +#~ msgstr "クリーンアップ対象リソース:" + +#~ msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +#~ msgstr "{count} 件のロールバック残留リソースを検出しました。クリーンアップフローを開始します。" + +#~ msgid "错误:{error}" +#~ msgstr "エラー: {error}" + +#~ msgid "删除中" +#~ msgstr "削除中" + +#~ msgid "完成" +#~ msgstr "完了" + +#~ msgid "失败" +#~ msgstr "失敗" + +#~ msgid "跳过" +#~ msgstr "スキップ" + +#~ msgid "待处理" +#~ msgstr "保留中" + +#~ msgid "检查" +#~ msgstr "確認" + +#~ msgid "进度" +#~ msgstr "進捗" + +#~ msgid "DeleteStack 已提交,等待删除完成({progress})" +#~ msgstr "DeleteStack を送信しました。削除完了を待機中({progress})" + +#~ msgid "DeleteStack 已提交,等待删除完成" +#~ msgstr "DeleteStack を送信しました。削除完了を待機中" + +#~ msgid "已跳过" +#~ msgstr "スキップ済み" + +#~ msgid "正在删除({progress})" +#~ msgstr "削除中({progress})" + +#~ msgid "{progress},需要删除" +#~ msgstr "{progress}、削除が必要" + +#~ msgid "资源栈" +#~ msgstr "リソーススタック" + +#~ msgid "资源" +#~ msgstr "リソース" + +#~ msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +#~ msgstr "ロールバッククリーンアップ記録を読み取れませんでした。クリーンアッププロンプトは保持されています。後で続行するか手動で確認してください。" + +#~ msgid "还有 {count} 个需要关注的资源未显示。" +#~ msgstr "注意が必要な未表示のリソースがさらに {count} 件あります。" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +#~ msgstr "↺ ロールバッククリーンアップ復元: {count} 件の記録はすべて完了済みです。" + +#~ msgid "进行中" +#~ msgstr "進行中" + +#~ msgid "已完成" +#~ msgstr "完了済み" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" +#~ msgstr "↺ ロールバッククリーンアップ復元: {count} 件の記録、{summary}。" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录。" +#~ msgstr "↺ ロールバッククリーンアップ復元: {count} 件の記録。" + +#~ msgid "Note: images are not supported in pipeline mode and will be ignored." +#~ msgstr "注: パイプラインモードでは画像はサポートされておらず、無視されます。" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 7594b9bc..40908db2 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -48,6 +48,14 @@ msgstr "Nome de arquivo de artefato inseguro" msgid "Unknown error" msgstr "Erro desconhecido" +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "" +"Cleanup state unavailable. Inspect the session file and cloud resources " +"manually." +msgstr "" +"Estado de limpeza indisponível. Inspecione manualmente o arquivo de " +"sessão e os recursos de nuvem." + #: src/iac_code/a2a/executor.py msgid "" "Rollback cleanup deferred prompt state is unavailable. Please repair it " @@ -81,6 +89,11 @@ msgstr "A tarefa já está em execução." msgid "Task canceled." msgstr "Tarefa cancelada." +#: src/iac_code/a2a/executor.py +#, fuzzy, python-brace-format +msgid "Current model {model} does not support image input." +msgstr "O modelo {model} não suporta nível de esforço (effort)." + #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." msgstr "Ocorreu um erro temporário. Tente novamente." @@ -89,6 +102,11 @@ msgstr "Ocorreu um erro temporário. Tente novamente." msgid "Authentication required. Configure credentials and retry." msgstr "Autenticação necessária. Configure as credenciais e tente novamente." +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "Pipeline already running. Resume task {task_id}." +msgstr "O pipeline já está em execução. Retome a tarefa {task_id}." + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "Estado do pipeline A2A não encontrado" @@ -521,6 +539,22 @@ msgstr "Ferramenta de orquestração de infraestrutura com IA" msgid "Use iac-code as an A2A client." msgstr "Usa o iac-code como cliente A2A." +#: src/iac_code/cli/main.py +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"As dependências do cliente A2A estão ausentes. Instale com: pip install " +"'iac-code[a2a]'" + +#: src/iac_code/cli/main.py +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"As dependências do servidor A2A estão ausentes. Instale com: pip install " +"'iac-code[a2a]'" + #: src/iac_code/cli/main.py msgid "Install Git for Windows via the npmmirror mirror (Windows only)." msgstr "Instalar Git for Windows pelo espelho npmmirror (somente Windows)." @@ -533,14 +567,6 @@ msgstr "Atualizar o iac-code para a versão mais recente." msgid "YAML config file containing A2A client options" msgstr "Arquivo de configuração YAML com opções do cliente A2A" -#: src/iac_code/cli/main.py -msgid "" -"A2A client dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "" -"As dependências do cliente A2A estão ausentes. Instale com: pip install " -"'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "LLM model to use" msgstr "Modelo LLM a utilizar" @@ -674,14 +700,6 @@ msgstr "" "Expõe tipos de sinal de thinking A2A; repita para múltiplos. Valores: " "raw-thinking, tool-trace." -#: src/iac_code/cli/main.py -msgid "" -"A2A server dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "" -"As dependências do servidor A2A estão ausentes. Instale com: pip install " -"'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "Send a prompt to an A2A JSON-RPC endpoint." msgstr "Envia um prompt para um endpoint JSON-RPC A2A." @@ -1631,7 +1649,8 @@ msgid "cleanup prompt" msgstr "prompt de limpeza" #: src/iac_code/commands/prompt.py -msgid "cleanup prompt · 已移除" +#, fuzzy +msgid "cleanup prompt · removed" msgstr "prompt de limpeza · removido" #: src/iac_code/commands/prompt.py @@ -2228,105 +2247,127 @@ msgid "User cancelled ask_user_question." msgstr "O usuário cancelou ask_user_question." #: src/iac_code/pipeline/engine/cleanup.py -msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +msgid "" +"Cloud resources still need cleanup after pipeline rollback. Clean them up" +" now and keep checking until deletion completes." msgstr "" -"Foram detectados recursos de nuvem que ainda precisam de limpeza após o " -"rollback do pipeline. Limpe-os imediatamente e verifique continuamente " -"até a exclusão terminar." +"Os recursos de nuvem ainda precisam de limpeza após o rollback do " +"pipeline. Limpe-os agora e continue verificando até que a exclusão seja " +"concluída." #: src/iac_code/pipeline/engine/cleanup.py -msgid "要求:" -msgstr "Requisitos:" +#, fuzzy +msgid "Requirements:" +msgstr "obrigatório" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +msgid "" +"- Cleanup scope is a strict allowlist: delete only ids in the cleanup " +"resources list below." msgstr "" -"- O escopo da limpeza é uma lista branca estrita: somente os ids na lista" -" \"Recursos a limpar\" abaixo podem ser excluídos." +"- O escopo de limpeza é uma allowlist estrita: exclua apenas os IDs na " +"lista de recursos de limpeza abaixo." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +msgid "" +"- Do not delete, modify, or roll back any stack or cloud resource outside" +" the cleanup resources list." msgstr "" -"- Não exclua, modifique nem faça rollback de nenhum stack ou recurso de " -"nuvem que não esteja em \"Recursos a limpar\"." +"- Não exclua, modifique nem reverta stacks ou recursos de nuvem fora da " +"lista de recursos de limpeza." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +msgid "" +"- Do not call ListStacks or search for other stacks by name; cleanup " +"resource ids are fully listed." msgstr "" -"- Nao chame ListStacks nem pesquise outros stacks por nome; os ids dos " -"recursos pendentes de limpeza ja estao completamente listados." +"- Não chame ListStacks nem pesquise outros stacks por nome; os IDs dos " +"recursos de limpeza estão totalmente listados." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +msgid "" +"- Before every GetStack/DeleteStack call, verify that StackId exactly " +"matches an id in the cleanup resources list." msgstr "" -"- Antes de cada chamada a GetStack/DeleteStack, verifique se StackId " -"corresponde exatamente a algum id da lista de recursos pendentes de " -"limpeza." +"- Antes de cada chamada GetStack/DeleteStack, verifique se StackId " +"corresponde exatamente a um ID na lista de recursos de limpeza." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +msgid "" +"- If StackId is not in the cleanup resources list, do not call " +"DeleteStack, even if it is the current handoff or newly created stack." msgstr "" -"- Se StackId nao estiver na lista de recursos pendentes de limpeza, e " -"proibido chamar DeleteStack, mesmo que seja o stack do handoff atual ou " -"recem-criado." +"- Se StackId não estiver na lista de recursos de limpeza, não chame " +"DeleteStack, mesmo que seja o stack atual do handoff ou recém-criado." #: src/iac_code/pipeline/engine/cleanup.py msgid "" -"- 不要根据 pipeline handoff、deployment.stack_id、current stack 或 " -"resources_created 额外推断清理对象;这些可能是最终成功交付的资源。" +"- Do not infer extra cleanup targets from pipeline handoff, " +"deployment.stack_id, current stack, or resources_created; those may be " +"final delivered resources." msgstr "" -"- Não deduza objetos extras de limpeza a partir de pipeline handoff, " -"deployment.stack_id, current stack ou resources_created; eles podem ser " -"recursos entregues com sucesso no final." +"- Não infira alvos de limpeza extras a partir do handoff do pipeline, " +"deployment.stack_id, stack atual ou resources_created; eles podem ser " +"recursos finais entregues." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +msgid "" +"- Do not expand cleanup scope for user follow-ups, continue instructions," +" or pipeline handoff context." msgstr "" -"- Mesmo que haja perguntas do usuário, instruções para continuar ou " -"contexto de pipeline handoff nesta rodada, não amplie o escopo da " -"limpeza." +"- Não expanda o escopo de limpeza por causa de acompanhamento do usuário," +" instruções de continue ou contexto de handoff do pipeline." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +msgid "" +"- When resuming cleanup, still process only resources listed in this " +"prompt; do not inspect or delete others." msgstr "" -"- Ao retomar ou continuar a limpeza, processe apenas os recursos listados" -" neste aviso; nao verifique nem exclua outros recursos." +"- Ao retomar a limpeza, processe apenas os recursos listados neste " +"prompt; não inspecione nem exclua outros." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 优先使用可用的 ROS stack 工具删除;如果改用 aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +msgid "" +"- Prefer available ROS stack tools for deletion; if using aliyun_api, " +"call DeleteStack first, then repeatedly call GetStack to check status." msgstr "" -"- Prefira usar a ferramenta ROS stack disponível para excluir; se usar " -"aliyun_api, execute DeleteStack primeiro e depois verifique o estado " -"repetidamente com GetStack." +"- Prefira as ferramentas de stack ROS disponíveis para exclusão; se usar " +"aliyun_api, chame DeleteStack primeiro e depois chame GetStack " +"repetidamente para verificar o status." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +msgid "" +"- If a resource is already deleting, call GetStack first, then decide " +"whether DeleteStack is needed again." msgstr "" -"- Se o recurso já estiver sendo excluído, use GetStack primeiro para " -"verificar o estado atual antes de decidir se precisa executar DeleteStack" -" novamente." +"- Se um recurso já estiver sendo excluído, chame GetStack primeiro e " +"depois decida se DeleteStack é necessário novamente." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +msgid "" +"- Cleanup is complete only after DELETE_COMPLETE; for DELETE_FAILED or " +"unknown status, tell the user the failure reason and next step." msgstr "" -"- A limpeza só é concluída após confirmar DELETE_COMPLETE; em caso de " -"DELETE_FAILED ou impossibilidade de confirmar, explique ao usuário a " -"causa da falha e o próximo passo." +"- A limpeza só está completa após DELETE_COMPLETE; para DELETE_FAILED ou " +"status desconhecido, informe ao usuário o motivo da falha e o próximo " +"passo." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +msgid "" +"- After all listed resources are DELETE_COMPLETE, stop this cleanup turn " +"immediately." msgstr "" -"- Quando todos os recursos da lista estiverem em DELETE_COMPLETE, " -"interrompa imediatamente esta limpeza; não exclua nem verifique nenhum " -"outro stack." +"- Depois que todos os recursos listados estiverem em DELETE_COMPLETE, " +"pare este turno de limpeza imediatamente." #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理过程中向用户简短同步进度。" -msgstr "- Informe brevemente o progresso ao usuário durante a limpeza." +msgid "- Briefly update the user during cleanup." +msgstr "- Atualize brevemente o usuário durante a limpeza." #: src/iac_code/pipeline/engine/cleanup.py -msgid "待清理资源:" -msgstr "Recursos a limpar:" +#, fuzzy +msgid "Cleanup resources:" +msgstr "Prompts de limpeza" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2339,10 +2380,8 @@ msgstr "" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format -msgid "检测到 {count} 个回滚残留资源,开始清理流程。" -msgstr "" -"{count} recursos residuais de rollback detectados; iniciando o fluxo de " -"limpeza." +msgid "Detected {count} rollback cleanup resources; starting cleanup." +msgstr "{count} recursos de limpeza de rollback detectados; iniciando limpeza." #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" @@ -2456,7 +2495,7 @@ msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" -msgstr "" +msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2539,6 +2578,11 @@ msgstr "" msgid "Step {step_id} completed. Conclusion submitted." msgstr "Etapa {step_id} concluída. Conclusão enviada." +#: src/iac_code/pipeline/engine/pipeline_runner.py +#, fuzzy +msgid "Pipeline state persistence failed." +msgstr "Estado do pipeline A2A não encontrado" + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -2641,6 +2685,10 @@ msgstr "" msgid "Template file path cannot escape the working directory" msgstr "O caminho do arquivo de template não pode sair do diretório de trabalho" +#: src/iac_code/pipeline/engine/user_input.py +msgid "[Image input]" +msgstr "[Entrada de imagem]" + #: src/iac_code/pipeline/selling/tools/show_candidate_detail_tool.py msgid "" "Display candidate details (summary and cost breakdown) in the comparison " @@ -3647,11 +3695,11 @@ msgstr "Interrompido" msgid "Running" msgstr "Em execução" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Completed" msgstr "Concluído" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Failed" msgstr "Falhou" @@ -3926,8 +3974,8 @@ msgid "Command has no handler: {name}" msgstr "Comando sem tratador: {name}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "↺ 回滚清理 [{badge}] {label}" +#, fuzzy, python-brace-format +msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ Limpeza de rollback [{badge}] {label}" #: src/iac_code/ui/repl.py @@ -3936,109 +3984,119 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "错误:{error}" -msgstr "Erro: {error}" - -#: src/iac_code/ui/repl.py -msgid "删除中" -msgstr "Excluindo" - -#: src/iac_code/ui/repl.py -msgid "完成" -msgstr "Concluído" - -#: src/iac_code/ui/repl.py -msgid "失败" -msgstr "Falhou" +#, fuzzy +msgid "Deleting" +msgstr "Implantação" #: src/iac_code/ui/repl.py -msgid "跳过" -msgstr "Ignorado" +#, fuzzy +msgid "Skipped" +msgstr "Ignorar" #: src/iac_code/ui/repl.py -msgid "待处理" -msgstr "Pendente" +#, fuzzy +msgid "Pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -msgid "检查" -msgstr "Verificar" +#, fuzzy +msgid "Checking" +msgstr "Verificação em andamento" #: src/iac_code/ui/repl.py -msgid "进度" -msgstr "Progresso" +#, fuzzy +msgid "Progress" +msgstr "Processado" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "DeleteStack 已提交,等待删除完成({progress})" -msgstr "DeleteStack enviado; aguardando a exclusão terminar ({progress})" +msgid "DeleteStack submitted; waiting for deletion to complete ({progress})" +msgstr "DeleteStack enviado; aguardando a conclusão da exclusão ({progress})" #: src/iac_code/ui/repl.py -msgid "DeleteStack 已提交,等待删除完成" -msgstr "DeleteStack enviado; aguardando a exclusão terminar" - -#: src/iac_code/ui/repl.py -msgid "已跳过" -msgstr "Ignorado" +msgid "DeleteStack submitted; waiting for deletion to complete" +msgstr "DeleteStack enviado; aguardando a conclusão da exclusão" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "正在删除({progress})" +msgid "Deleting ({progress})" msgstr "Excluindo ({progress})" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "{progress},需要删除" -msgstr "{progress}, requer exclusão" +msgid "{progress}; deletion required" +msgstr "{progress}; exclusão necessária" #: src/iac_code/ui/repl.py -msgid "资源栈" -msgstr "Stack de recursos" +#, fuzzy +msgid "stack" +msgstr "ROS Stack" #: src/iac_code/ui/repl.py -msgid "资源" +#, fuzzy +msgid "resource" msgstr "Recurso" #: src/iac_code/ui/repl.py -msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +msgid "" +"Could not read rollback cleanup records. The cleanup prompt was kept; " +"retry later or inspect manually." msgstr "" -"Não foi possível ler o registro de limpeza de rollback; o prompt de " -"limpeza foi preservado. Continue mais tarde ou verifique manualmente." +"Não foi possível ler os registros de limpeza de rollback. O prompt de " +"limpeza foi mantido; tente novamente mais tarde ou inspecione " +"manualmente." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "还有 {count} 个需要关注的资源未显示。" -msgstr "Há mais {count} recursos que precisam de atenção e não são exibidos." +msgid "{count} additional resources needing attention were not shown." +msgstr "{count} recursos adicionais que precisam de atenção não foram exibidos." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +msgid "↺ Rollback cleanup resume: all {count} records are completed." msgstr "" -"↺ Recuperação da limpeza de rollback: todos os {count} registros foram " +"↺ Retomada da limpeza de rollback: todos os {count} registros foram " "concluídos." #: src/iac_code/ui/repl.py -msgid "进行中" -msgstr "Em andamento" +#, fuzzy +msgid "failed" +msgstr "Falhou" #: src/iac_code/ui/repl.py -msgid "已完成" +#, fuzzy +msgid "pending" +msgstr "OpenAI" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "in progress" +msgstr "Verificação em andamento" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "completed" msgstr "Concluído" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "{count} 条{label}" +#, fuzzy +msgid "skipped" +msgstr "Ignorar" + +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "{count} {label}" msgstr "{count} {label}" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" -msgstr "↺ Recuperação da limpeza de rollback: {count} registros, {summary}." +msgid "↺ Rollback cleanup resume: {count} records, {summary}." +msgstr "↺ Retomada da limpeza de rollback: {count} registros, {summary}." #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录。" -msgstr "↺ Recuperação da limpeza de rollback: {count} registros." +msgid "↺ Rollback cleanup resume: {count} records." +msgstr "↺ Retomada da limpeza de rollback: {count} registros." #: src/iac_code/ui/repl.py #, python-brace-format @@ -4051,10 +4109,6 @@ msgstr "" "Recursos de limpeza de rollback foram detectados, mas a injeção do prompt" " de limpeza falhou." -#: src/iac_code/ui/repl.py -msgid "Note: images are not supported in pipeline mode and will be ignored." -msgstr "Observação: imagens não são suportadas no modo pipeline e serão ignoradas." - #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -4062,8 +4116,25 @@ msgstr "Ignorando estado salvo do pipeline: {reason}" #: src/iac_code/ui/repl.py msgid "" -"Pipeline completed. Normal chat is active, but the handoff context could " -"not be injected or saved." +"Pipeline state persistence failed. The pipeline is paused; do not " +"continue until state is durable." +msgstr "" +"Falha ao persistir o estado do pipeline. O pipeline está pausado; não " +"continue até que o estado esteja durável." + +#: src/iac_code/ui/repl.py +msgid "" +"Pipeline state persistence failed. Normal chat handoff was not marked " +"durable." +msgstr "" +"Falha ao persistir o estado do pipeline. O handoff para chat normal não " +"foi marcado como durável." + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "" +"Pipeline completed, but the handoff context could not be injected or " +"saved." msgstr "" "O pipeline foi concluído. O chat normal está ativo, mas o contexto de " "transferência não pôde ser injetado ou salvo." @@ -4325,6 +4396,16 @@ msgstr "erro desconhecido" msgid "Resumed pipeline at step: {step}" msgstr "Pipeline retomado na etapa: {step}" +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "Could not read pipeline state metadata: {reason}" +msgstr "Não foi possível retomar o estado do pipeline: {reason}" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "Pipeline state metadata is invalid; continuing as normal chat." +msgstr "Descartar estado do pipeline e continuar como chat normal" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Found pipeline state in this session (paused at: {step})." @@ -4872,3 +4953,199 @@ msgstr "Rastreamento de pilha omitido do evento público; veja error_id." #~ msgid "Project Memory Index" #~ msgstr "Índice de memória do projeto" + +#~ msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +#~ msgstr "" +#~ "Foram detectados recursos de nuvem que" +#~ " ainda precisam de limpeza após o " +#~ "rollback do pipeline. Limpe-os " +#~ "imediatamente e verifique continuamente até" +#~ " a exclusão terminar." + +#~ msgid "要求:" +#~ msgstr "Requisitos:" + +#~ msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +#~ msgstr "" +#~ "- O escopo da limpeza é uma " +#~ "lista branca estrita: somente os ids " +#~ "na lista \"Recursos a limpar\" abaixo" +#~ " podem ser excluídos." + +#~ msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +#~ msgstr "" +#~ "- Não exclua, modifique nem faça " +#~ "rollback de nenhum stack ou recurso " +#~ "de nuvem que não esteja em " +#~ "\"Recursos a limpar\"." + +#~ msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +#~ msgstr "" +#~ "- Nao chame ListStacks nem pesquise " +#~ "outros stacks por nome; os ids dos" +#~ " recursos pendentes de limpeza ja " +#~ "estao completamente listados." + +#~ msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +#~ msgstr "" +#~ "- Antes de cada chamada a " +#~ "GetStack/DeleteStack, verifique se StackId " +#~ "corresponde exatamente a algum id da " +#~ "lista de recursos pendentes de limpeza." + +#~ msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +#~ msgstr "" +#~ "- Se StackId nao estiver na lista" +#~ " de recursos pendentes de limpeza, e" +#~ " proibido chamar DeleteStack, mesmo que " +#~ "seja o stack do handoff atual ou" +#~ " recem-criado." + +#~ msgid "" +#~ "- 不要根据 pipeline handoff、deployment.stack_id、current" +#~ " stack 或 resources_created " +#~ "额外推断清理对象;这些可能是最终成功交付的资源。" +#~ msgstr "" +#~ "- Não deduza objetos extras de " +#~ "limpeza a partir de pipeline handoff," +#~ " deployment.stack_id, current stack ou " +#~ "resources_created; eles podem ser recursos " +#~ "entregues com sucesso no final." + +#~ msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +#~ msgstr "" +#~ "- Mesmo que haja perguntas do " +#~ "usuário, instruções para continuar ou " +#~ "contexto de pipeline handoff nesta " +#~ "rodada, não amplie o escopo da " +#~ "limpeza." + +#~ msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +#~ msgstr "" +#~ "- Ao retomar ou continuar a " +#~ "limpeza, processe apenas os recursos " +#~ "listados neste aviso; nao verifique nem" +#~ " exclua outros recursos." + +#~ msgid "" +#~ "- 优先使用可用的 ROS stack 工具删除;如果改用 " +#~ "aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +#~ msgstr "" +#~ "- Prefira usar a ferramenta ROS " +#~ "stack disponível para excluir; se usar" +#~ " aliyun_api, execute DeleteStack primeiro e" +#~ " depois verifique o estado repetidamente" +#~ " com GetStack." + +#~ msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +#~ msgstr "" +#~ "- Se o recurso já estiver sendo" +#~ " excluído, use GetStack primeiro para " +#~ "verificar o estado atual antes de " +#~ "decidir se precisa executar DeleteStack " +#~ "novamente." + +#~ msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +#~ msgstr "" +#~ "- A limpeza só é concluída após" +#~ " confirmar DELETE_COMPLETE; em caso de " +#~ "DELETE_FAILED ou impossibilidade de confirmar," +#~ " explique ao usuário a causa da " +#~ "falha e o próximo passo." + +#~ msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +#~ msgstr "" +#~ "- Quando todos os recursos da " +#~ "lista estiverem em DELETE_COMPLETE, interrompa" +#~ " imediatamente esta limpeza; não exclua " +#~ "nem verifique nenhum outro stack." + +#~ msgid "- 清理过程中向用户简短同步进度。" +#~ msgstr "- Informe brevemente o progresso ao usuário durante a limpeza." + +#~ msgid "待清理资源:" +#~ msgstr "Recursos a limpar:" + +#~ msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +#~ msgstr "" +#~ "{count} recursos residuais de rollback " +#~ "detectados; iniciando o fluxo de " +#~ "limpeza." + +#~ msgid "错误:{error}" +#~ msgstr "Erro: {error}" + +#~ msgid "删除中" +#~ msgstr "Excluindo" + +#~ msgid "完成" +#~ msgstr "Concluído" + +#~ msgid "失败" +#~ msgstr "Falhou" + +#~ msgid "跳过" +#~ msgstr "Ignorado" + +#~ msgid "待处理" +#~ msgstr "Pendente" + +#~ msgid "检查" +#~ msgstr "Verificar" + +#~ msgid "进度" +#~ msgstr "Progresso" + +#~ msgid "DeleteStack 已提交,等待删除完成({progress})" +#~ msgstr "DeleteStack enviado; aguardando a exclusão terminar ({progress})" + +#~ msgid "DeleteStack 已提交,等待删除完成" +#~ msgstr "DeleteStack enviado; aguardando a exclusão terminar" + +#~ msgid "已跳过" +#~ msgstr "Ignorado" + +#~ msgid "正在删除({progress})" +#~ msgstr "Excluindo ({progress})" + +#~ msgid "{progress},需要删除" +#~ msgstr "{progress}, requer exclusão" + +#~ msgid "资源栈" +#~ msgstr "Stack de recursos" + +#~ msgid "资源" +#~ msgstr "Recurso" + +#~ msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +#~ msgstr "" +#~ "Não foi possível ler o registro de" +#~ " limpeza de rollback; o prompt de " +#~ "limpeza foi preservado. Continue mais " +#~ "tarde ou verifique manualmente." + +#~ msgid "还有 {count} 个需要关注的资源未显示。" +#~ msgstr "Há mais {count} recursos que precisam de atenção e não são exibidos." + +#~ msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +#~ msgstr "" +#~ "↺ Recuperação da limpeza de rollback:" +#~ " todos os {count} registros foram " +#~ "concluídos." + +#~ msgid "进行中" +#~ msgstr "Em andamento" + +#~ msgid "已完成" +#~ msgstr "Concluído" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" +#~ msgstr "↺ Recuperação da limpeza de rollback: {count} registros, {summary}." + +#~ msgid "↺ 回滚清理恢复:{count} 条记录。" +#~ msgstr "↺ Recuperação da limpeza de rollback: {count} registros." + +#~ msgid "Note: images are not supported in pipeline mode and will be ignored." +#~ msgstr "" +#~ "Observação: imagens não são suportadas " +#~ "no modo pipeline e serão ignoradas." diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 1ca2031a..4e089d3d 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -44,6 +44,12 @@ msgstr "不安全的工件文件名" msgid "Unknown error" msgstr "未知错误" +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "" +"Cleanup state unavailable. Inspect the session file and cloud resources " +"manually." +msgstr "清理状态不可用。请手动检查会话文件和云资源。" + #: src/iac_code/a2a/executor.py msgid "" "Rollback cleanup deferred prompt state is unavailable. Please repair it " @@ -71,6 +77,11 @@ msgstr "任务已在运行。" msgid "Task canceled." msgstr "任务已取消。" +#: src/iac_code/a2a/executor.py +#, fuzzy, python-brace-format +msgid "Current model {model} does not support image input." +msgstr "模型 {model} 不支持调整思考强度。" + #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." msgstr "发生临时错误,请重试。" @@ -79,6 +90,11 @@ msgstr "发生临时错误,请重试。" msgid "Authentication required. Configure credentials and retry." msgstr "需要身份验证。请配置凭据后重试。" +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "Pipeline already running. Resume task {task_id}." +msgstr "管道已在运行。请恢复任务 {task_id}。" + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "未找到 A2A pipeline 状态" @@ -491,6 +507,18 @@ msgstr "AI 驱动的基础设施编排工具" msgid "Use iac-code as an A2A client." msgstr "将 iac-code 作为 A2A 客户端使用。" +#: src/iac_code/cli/main.py +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "缺少 A2A 客户端依赖。请使用以下命令安装:pip install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "缺少 A2A 服务器依赖。请使用以下命令安装:pip install 'iac-code[a2a]'" + #: src/iac_code/cli/main.py msgid "Install Git for Windows via the npmmirror mirror (Windows only)." msgstr "通过 npmmirror 镜像安装 Git for Windows(仅 Windows)。" @@ -503,12 +531,6 @@ msgstr "将 iac-code 更新到最新版本。" msgid "YAML config file containing A2A client options" msgstr "包含 A2A 客户端选项的 YAML 配置文件" -#: src/iac_code/cli/main.py -msgid "" -"A2A client dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "缺少 A2A 客户端依赖。请使用以下命令安装:pip install 'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "LLM model to use" msgstr "使用的 LLM 模型" @@ -630,12 +652,6 @@ msgid "" "thinking, tool-trace." msgstr "暴露 A2A thinking 信号类型;可重复指定多个。取值:raw-thinking、tool-trace。" -#: src/iac_code/cli/main.py -msgid "" -"A2A server dependencies are missing. Install with: pip install 'iac-" -"code[a2a]'" -msgstr "缺少 A2A 服务器依赖。请使用以下命令安装:pip install 'iac-code[a2a]'" - #: src/iac_code/cli/main.py msgid "Send a prompt to an A2A JSON-RPC endpoint." msgstr "向 A2A JSON-RPC 端点发送提示。" @@ -1581,7 +1597,8 @@ msgid "cleanup prompt" msgstr "清理提示词" #: src/iac_code/commands/prompt.py -msgid "cleanup prompt · 已移除" +#, fuzzy +msgid "cleanup prompt · removed" msgstr "清理提示词 · 已移除" #: src/iac_code/commands/prompt.py @@ -2149,72 +2166,101 @@ msgid "User cancelled ask_user_question." msgstr "用户取消了 ask_user_question。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" -msgstr "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +msgid "" +"Cloud resources still need cleanup after pipeline rollback. Clean them up" +" now and keep checking until deletion completes." +msgstr "管道回滚后仍有云资源需要清理。请立即清理,并持续检查直到删除完成。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "要求:" -msgstr "要求:" +#, fuzzy +msgid "Requirements:" +msgstr "必填" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" -msgstr "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +msgid "" +"- Cleanup scope is a strict allowlist: delete only ids in the cleanup " +"resources list below." +msgstr "- 清理范围是严格白名单:只删除下面清理资源列表中的 ID。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" -msgstr "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +msgid "" +"- Do not delete, modify, or roll back any stack or cloud resource outside" +" the cleanup resources list." +msgstr "- 不要删除、修改或回滚清理资源列表之外的任何资源栈或云资源。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" -msgstr "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +msgid "" +"- Do not call ListStacks or search for other stacks by name; cleanup " +"resource ids are fully listed." +msgstr "- 不要调用 ListStacks,也不要按名称搜索其他资源栈;清理资源 ID 已完整列出。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" -msgstr "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +msgid "" +"- Before every GetStack/DeleteStack call, verify that StackId exactly " +"matches an id in the cleanup resources list." +msgstr "- 每次调用 GetStack/DeleteStack 前,确认 StackId 与清理资源列表中的某个 ID 完全一致。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" -msgstr "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +msgid "" +"- If StackId is not in the cleanup resources list, do not call " +"DeleteStack, even if it is the current handoff or newly created stack." +msgstr "- 如果 StackId 不在清理资源列表中,不要调用 DeleteStack,即使它是当前 handoff 或新创建的资源栈。" #: src/iac_code/pipeline/engine/cleanup.py msgid "" -"- 不要根据 pipeline handoff、deployment.stack_id、current stack 或 " -"resources_created 额外推断清理对象;这些可能是最终成功交付的资源。" +"- Do not infer extra cleanup targets from pipeline handoff, " +"deployment.stack_id, current stack, or resources_created; those may be " +"final delivered resources." msgstr "" -"- 不要根据 pipeline handoff、deployment.stack_id、current stack 或 " -"resources_created 额外推断清理对象;这些可能是最终成功交付的资源。" +"- 不要从管道 handoff、deployment.stack_id、当前资源栈或 resources_created " +"推断额外清理目标;它们可能是最终交付资源。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" -msgstr "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +msgid "" +"- Do not expand cleanup scope for user follow-ups, continue instructions," +" or pipeline handoff context." +msgstr "- 不要因为用户追问、continue 指令或管道 handoff 上下文而扩大清理范围。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" -msgstr "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +msgid "" +"- When resuming cleanup, still process only resources listed in this " +"prompt; do not inspect or delete others." +msgstr "- 恢复清理时,仍然只处理此提示中列出的资源;不要检查或删除其他资源。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 优先使用可用的 ROS stack 工具删除;如果改用 aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" -msgstr "- 优先使用可用的 ROS stack 工具删除;如果改用 aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +msgid "" +"- Prefer available ROS stack tools for deletion; if using aliyun_api, " +"call DeleteStack first, then repeatedly call GetStack to check status." +msgstr "" +"- 删除时优先使用可用的 ROS 资源栈工具;如果使用 aliyun_api,请先调用 DeleteStack,然后反复调用 GetStack " +"检查状态。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" -msgstr "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +msgid "" +"- If a resource is already deleting, call GetStack first, then decide " +"whether DeleteStack is needed again." +msgstr "- 如果资源已在删除中,请先调用 GetStack,再判断是否需要再次调用 DeleteStack。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" -msgstr "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +msgid "" +"- Cleanup is complete only after DELETE_COMPLETE; for DELETE_FAILED or " +"unknown status, tell the user the failure reason and next step." +msgstr "- 只有达到 DELETE_COMPLETE 后清理才算完成;如果是 DELETE_FAILED 或未知状态,请告诉用户失败原因和下一步。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" -msgstr "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +msgid "" +"- After all listed resources are DELETE_COMPLETE, stop this cleanup turn " +"immediately." +msgstr "- 所有列出的资源达到 DELETE_COMPLETE 后,立即停止本轮清理。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "- 清理过程中向用户简短同步进度。" -msgstr "- 清理过程中向用户简短同步进度。" +msgid "- Briefly update the user during cleanup." +msgstr "- 清理过程中简要向用户更新进展。" #: src/iac_code/pipeline/engine/cleanup.py -msgid "待清理资源:" -msgstr "待清理资源:" +#, fuzzy +msgid "Cleanup resources:" +msgstr "清理提示词" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2222,13 +2268,13 @@ msgid "" "{index}. provider={provider}, type={resource_type}, id={resource_id}, " "name={name}, region={region}" msgstr "" -"{index}. provider={provider}, type={resource_type}, id={resource_id}, " -"name={name}, region={region}" +"{index}. 提供方={provider}, 类型={resource_type}, ID={resource_id}, 名称={name}," +" 地域={region}" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format -msgid "检测到 {count} 个回滚残留资源,开始清理流程。" -msgstr "检测到 {count} 个回滚残留资源,开始清理流程。" +msgid "Detected {count} rollback cleanup resources; starting cleanup." +msgstr "检测到 {count} 个回滚清理资源;开始清理。" #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" @@ -2329,7 +2375,7 @@ msgstr "{message} complete_step.conclusion.{field} 必须与 {tool} 结果值 {v #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" -msgstr "" +msgstr "<缺失>" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -2399,6 +2445,11 @@ msgstr "conclusion 校验失败;请修正后重新调用 complete_step: {error msgid "Step {step_id} completed. Conclusion submitted." msgstr "步骤 {step_id} 已完成。结论已提交。" +#: src/iac_code/pipeline/engine/pipeline_runner.py +#, fuzzy +msgid "Pipeline state persistence failed." +msgstr "未找到 A2A pipeline 状态" + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -2492,6 +2543,10 @@ msgstr "模板文件路径必须是相对于工作目录的路径" msgid "Template file path cannot escape the working directory" msgstr "模板文件路径不能跳出工作目录" +#: src/iac_code/pipeline/engine/user_input.py +msgid "[Image input]" +msgstr "[图片输入]" + #: src/iac_code/pipeline/selling/tools/show_candidate_detail_tool.py msgid "" "Display candidate details (summary and cost breakdown) in the comparison " @@ -3456,11 +3511,11 @@ msgstr "已中断" msgid "Running" msgstr "进行中" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Completed" msgstr "已完成" -#: src/iac_code/ui/pipeline_display_replay.py +#: src/iac_code/ui/pipeline_display_replay.py src/iac_code/ui/repl.py msgid "Failed" msgstr "失败" @@ -3731,8 +3786,8 @@ msgid "Command has no handler: {name}" msgstr "命令没有处理器:{name}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "↺ 回滚清理 [{badge}] {label}" +#, fuzzy, python-brace-format +msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ 回滚清理 [{badge}] {label}" #: src/iac_code/ui/repl.py @@ -3741,104 +3796,113 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "错误:{error}" -msgstr "错误:{error}" - -#: src/iac_code/ui/repl.py -msgid "删除中" -msgstr "删除中" - -#: src/iac_code/ui/repl.py -msgid "完成" -msgstr "完成" - -#: src/iac_code/ui/repl.py -msgid "失败" -msgstr "失败" +#, fuzzy +msgid "Deleting" +msgstr "部署执行" #: src/iac_code/ui/repl.py -msgid "跳过" +#, fuzzy +msgid "Skipped" msgstr "跳过" #: src/iac_code/ui/repl.py -msgid "待处理" -msgstr "待处理" +#, fuzzy +msgid "Pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -msgid "检查" -msgstr "检查" +#, fuzzy +msgid "Checking" +msgstr "检查中" #: src/iac_code/ui/repl.py -msgid "进度" -msgstr "进度" +#, fuzzy +msgid "Progress" +msgstr "已处理" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "DeleteStack 已提交,等待删除完成({progress})" -msgstr "DeleteStack 已提交,等待删除完成({progress})" +msgid "DeleteStack submitted; waiting for deletion to complete ({progress})" +msgstr "DeleteStack 已提交;等待删除完成({progress})" #: src/iac_code/ui/repl.py -msgid "DeleteStack 已提交,等待删除完成" -msgstr "DeleteStack 已提交,等待删除完成" - -#: src/iac_code/ui/repl.py -msgid "已跳过" -msgstr "已跳过" +msgid "DeleteStack submitted; waiting for deletion to complete" +msgstr "DeleteStack 已提交;等待删除完成" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "正在删除({progress})" -msgstr "正在删除({progress})" +msgid "Deleting ({progress})" +msgstr "删除中({progress})" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "{progress},需要删除" -msgstr "{progress},需要删除" +msgid "{progress}; deletion required" +msgstr "{progress};需要删除" #: src/iac_code/ui/repl.py -msgid "资源栈" -msgstr "资源栈" +#, fuzzy +msgid "stack" +msgstr "ROS 资源栈" #: src/iac_code/ui/repl.py -msgid "资源" +#, fuzzy +msgid "resource" msgstr "资源" #: src/iac_code/ui/repl.py -msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" -msgstr "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +msgid "" +"Could not read rollback cleanup records. The cleanup prompt was kept; " +"retry later or inspect manually." +msgstr "无法读取回滚清理记录。清理提示已保留;请稍后重试或手动检查。" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "还有 {count} 个需要关注的资源未显示。" -msgstr "还有 {count} 个需要关注的资源未显示。" +msgid "{count} additional resources needing attention were not shown." +msgstr "还有 {count} 个需要注意的资源未显示。" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" -msgstr "↺ 回滚清理恢复:{count} 条记录均已完成。" +msgid "↺ Rollback cleanup resume: all {count} records are completed." +msgstr "↺ 回滚清理恢复:所有 {count} 条记录都已完成。" #: src/iac_code/ui/repl.py -msgid "进行中" -msgstr "进行中" +#, fuzzy +msgid "failed" +msgstr "失败" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "pending" +msgstr "OpenAI" #: src/iac_code/ui/repl.py -msgid "已完成" +#, fuzzy +msgid "in progress" +msgstr "检查中" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "completed" msgstr "已完成" #: src/iac_code/ui/repl.py -#, python-brace-format -msgid "{count} 条{label}" +#, fuzzy +msgid "skipped" +msgstr "跳过" + +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "{count} {label}" msgstr "{count} 条{label}" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" +msgid "↺ Rollback cleanup resume: {count} records, {summary}." msgstr "↺ 回滚清理恢复:{count} 条记录,{summary}。" #: src/iac_code/ui/repl.py #, python-brace-format -msgid "↺ 回滚清理恢复:{count} 条记录。" +msgid "↺ Rollback cleanup resume: {count} records." msgstr "↺ 回滚清理恢复:{count} 条记录。" #: src/iac_code/ui/repl.py @@ -3850,10 +3914,6 @@ msgstr " [{badge}] {label}" msgid "Detected rollback cleanup resources, but cleanup prompt injection failed." msgstr "检测到回滚清理资源,但清理提示注入失败。" -#: src/iac_code/ui/repl.py -msgid "Note: images are not supported in pipeline mode and will be ignored." -msgstr "注意:pipeline 模式不支持图像,将忽略图像。" - #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -3861,8 +3921,21 @@ msgstr "正在忽略已保存的 pipeline 状态:{reason}" #: src/iac_code/ui/repl.py msgid "" -"Pipeline completed. Normal chat is active, but the handoff context could " -"not be injected or saved." +"Pipeline state persistence failed. The pipeline is paused; do not " +"continue until state is durable." +msgstr "管道状态持久化失败。管道已暂停;在状态可靠持久化之前不要继续。" + +#: src/iac_code/ui/repl.py +msgid "" +"Pipeline state persistence failed. Normal chat handoff was not marked " +"durable." +msgstr "管道状态持久化失败。普通聊天 handoff 未标记为已持久化。" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "" +"Pipeline completed, but the handoff context could not be injected or " +"saved." msgstr "Pipeline 已完成。普通聊天已启用,但交接上下文无法注入或保存。" #: src/iac_code/ui/repl.py @@ -4106,6 +4179,16 @@ msgstr "未知错误" msgid "Resumed pipeline at step: {step}" msgstr "已恢复 pipeline 到步骤:{step}" +#: src/iac_code/ui/repl.py +#, fuzzy, python-brace-format +msgid "Could not read pipeline state metadata: {reason}" +msgstr "无法恢复 pipeline 状态:{reason}" + +#: src/iac_code/ui/repl.py +#, fuzzy +msgid "Pipeline state metadata is invalid; continuing as normal chat." +msgstr "丢弃 pipeline 状态并按普通聊天继续" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Found pipeline state in this session (paused at: {step})." @@ -4644,3 +4727,133 @@ msgstr "公开事件中已省略堆栈跟踪;请查看 error_id。" #~ msgid "Project Memory Index" #~ msgstr "项目记忆索引" + +#~ msgid "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" +#~ msgstr "检测到 pipeline rollback 后仍需要清理的云资源。请立即清理这些资源,并持续检查直到删除完成。" + +#~ msgid "要求:" +#~ msgstr "要求:" + +#~ msgid "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" +#~ msgstr "- 清理范围是严格白名单:只能删除下面“待清理资源”列表中的 id。" + +#~ msgid "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" +#~ msgstr "- 不要删除、修改或回滚任何未列入“待清理资源”的 stack 或云资源。" + +#~ msgid "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" +#~ msgstr "- 不要调用 ListStacks 或按名称搜索其它 stack;待清理资源 id 已完整列出。" + +#~ msgid "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" +#~ msgstr "- 每次调用 GetStack/DeleteStack 前,必须核对 StackId 精确等于“待清理资源”列表中的某个 id。" + +#~ msgid "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" +#~ msgstr "- 如果 StackId 不在“待清理资源”列表中,禁止调用 DeleteStack,即使它是当前 handoff 或刚创建的 stack。" + +#~ msgid "" +#~ "- 不要根据 pipeline handoff、deployment.stack_id、current" +#~ " stack 或 resources_created " +#~ "额外推断清理对象;这些可能是最终成功交付的资源。" +#~ msgstr "" +#~ "- 不要根据 pipeline handoff、deployment.stack_id、current" +#~ " stack 或 resources_created " +#~ "额外推断清理对象;这些可能是最终成功交付的资源。" + +#~ msgid "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" +#~ msgstr "- 即使本轮还有用户追问、继续指令或 pipeline handoff 上下文,也不能扩大清理范围。" + +#~ msgid "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" +#~ msgstr "- 恢复或继续清理时仍只处理当前提示列出的资源;不要检查或删除其它资源。" + +#~ msgid "" +#~ "- 优先使用可用的 ROS stack 工具删除;如果改用 " +#~ "aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" +#~ msgstr "" +#~ "- 优先使用可用的 ROS stack 工具删除;如果改用 " +#~ "aliyun_api,请先 DeleteStack,再反复 GetStack 检查状态。" + +#~ msgid "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" +#~ msgstr "- 如果资源已经处于删除中,请先 GetStack 检查当前状态,再决定是否需要重新 DeleteStack。" + +#~ msgid "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" +#~ msgstr "- 只有确认 DELETE_COMPLETE 才算清理完成;DELETE_FAILED 或无法确认时要向用户说明失败原因和下一步。" + +#~ msgid "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" +#~ msgstr "- 列表内资源全部 DELETE_COMPLETE 后,立刻停止本轮清理;不要继续删除或检查任何其他 stack。" + +#~ msgid "- 清理过程中向用户简短同步进度。" +#~ msgstr "- 清理过程中向用户简短同步进度。" + +#~ msgid "待清理资源:" +#~ msgstr "待清理资源:" + +#~ msgid "检测到 {count} 个回滚残留资源,开始清理流程。" +#~ msgstr "检测到 {count} 个回滚残留资源,开始清理流程。" + +#~ msgid "错误:{error}" +#~ msgstr "错误:{error}" + +#~ msgid "删除中" +#~ msgstr "删除中" + +#~ msgid "完成" +#~ msgstr "完成" + +#~ msgid "失败" +#~ msgstr "失败" + +#~ msgid "跳过" +#~ msgstr "跳过" + +#~ msgid "待处理" +#~ msgstr "待处理" + +#~ msgid "检查" +#~ msgstr "检查" + +#~ msgid "进度" +#~ msgstr "进度" + +#~ msgid "DeleteStack 已提交,等待删除完成({progress})" +#~ msgstr "DeleteStack 已提交,等待删除完成({progress})" + +#~ msgid "DeleteStack 已提交,等待删除完成" +#~ msgstr "DeleteStack 已提交,等待删除完成" + +#~ msgid "已跳过" +#~ msgstr "已跳过" + +#~ msgid "正在删除({progress})" +#~ msgstr "正在删除({progress})" + +#~ msgid "{progress},需要删除" +#~ msgstr "{progress},需要删除" + +#~ msgid "资源栈" +#~ msgstr "资源栈" + +#~ msgid "资源" +#~ msgstr "资源" + +#~ msgid "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" +#~ msgstr "无法读取回滚清理记录,已保留清理提示,请稍后继续或手动检查。" + +#~ msgid "还有 {count} 个需要关注的资源未显示。" +#~ msgstr "还有 {count} 个需要关注的资源未显示。" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录均已完成。" +#~ msgstr "↺ 回滚清理恢复:{count} 条记录均已完成。" + +#~ msgid "进行中" +#~ msgstr "进行中" + +#~ msgid "已完成" +#~ msgstr "已完成" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录,{summary}。" +#~ msgstr "↺ 回滚清理恢复:{count} 条记录,{summary}。" + +#~ msgid "↺ 回滚清理恢复:{count} 条记录。" +#~ msgstr "↺ 回滚清理恢复:{count} 条记录。" + +#~ msgid "Note: images are not supported in pipeline mode and will be ignored." +#~ msgstr "注意:pipeline 模式不支持图像,将忽略图像。" From cdc03eea00998adcc536cc1a1d9db5fccafe90c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Wed, 24 Jun 2026 13:35:38 +0800 Subject: [PATCH 56/59] fix: address pipeline review findings --- src/iac_code/a2a/app.py | 56 ++-------- src/iac_code/a2a/executor.py | 16 +-- src/iac_code/a2a/jsonrpc_passthrough.py | 88 +++++++++++++++ src/iac_code/a2a/pipeline_events.py | 15 +++ src/iac_code/a2a/pipeline_executor.py | 32 ------ src/iac_code/a2a/pipeline_snapshot.py | 59 +++++++++- src/iac_code/a2a/pipeline_stream.py | 17 ++- src/iac_code/a2a/transports/dispatcher.py | 7 +- src/iac_code/agent/agent_loop.py | 1 + src/iac_code/pipeline/engine/cleanup.py | 95 +++++++++++----- src/iac_code/pipeline/engine/events.py | 1 + .../pipeline/engine/pipeline_runner.py | 88 ++++++++++++--- src/iac_code/tools/path_safety.py | 46 +++++++- src/iac_code/tools/read_file.py | 18 +--- src/iac_code/ui/repl.py | 18 +++- src/iac_code/utils/path_locks.py | 35 ++++++ src/iac_code/utils/state_io.py | 50 ++++++--- tests/a2a/test_executor_cleanup.py | 10 +- tests/a2a/test_jsonrpc_passthrough.py | 50 +++++++++ tests/a2a/test_pipeline_events.py | 25 +++++ tests/a2a/test_pipeline_executor.py | 35 +++++- tests/a2a/test_pipeline_recovery.py | 43 ++++++++ tests/a2a/test_pipeline_snapshot.py | 20 ++++ tests/a2a/test_pipeline_stream.py | 13 ++- tests/a2a/test_selling_console_script.py | 17 +++ tests/agent/test_agent_loop_continue.py | 4 +- tests/pipeline/engine/test_cleanup.py | 20 +++- tests/pipeline/engine/test_display_replay.py | 20 ++++ .../engine/test_pipeline_runner_cleanup.py | 101 ++++++++++++++++++ tests/tools/test_path_safety.py | 20 ++++ tests/tools/test_read_file.py | 16 ++- tests/ui/test_pipeline_interrupt_ui.py | 27 +++++ tests/utils/test_state_io.py | 89 ++++++++++++++- 33 files changed, 963 insertions(+), 189 deletions(-) create mode 100644 src/iac_code/a2a/jsonrpc_passthrough.py create mode 100644 src/iac_code/utils/path_locks.py create mode 100644 tests/a2a/test_jsonrpc_passthrough.py diff --git a/src/iac_code/a2a/app.py b/src/iac_code/a2a/app.py index 83174739..096b5471 100644 --- a/src/iac_code/a2a/app.py +++ b/src/iac_code/a2a/app.py @@ -12,14 +12,12 @@ from email.utils import formatdate from pathlib import Path from time import time -from types import MethodType -from typing import Any, AsyncIterable, AsyncIterator, Awaitable, Callable +from typing import Awaitable, Callable from a2a.auth.user import User from a2a.server.context import ServerCallContext from a2a.server.routes import create_jsonrpc_routes, create_rest_routes from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH -from sse_starlette.sse import EventSourceResponse from starlette.applications import Starlette from starlette.authentication import AuthCredentials, SimpleUser from starlette.middleware.base import BaseHTTPMiddleware @@ -28,6 +26,10 @@ 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__) @@ -161,53 +163,6 @@ async def normalize_v03_jsonrpc_version(request: Request) -> None: delattr(request, "_headers") -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 - - def create_app( *, host: str, @@ -324,6 +279,7 @@ 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) diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 465e5bcc..f50710c9 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -44,6 +44,12 @@ 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, @@ -109,7 +115,6 @@ def _cleanup_ledger_path_from_handoff(handoff: dict[str, Any]) -> str | None: def _cleanup_payload_from_private_ledger_or_unavailable( *, ledger_path: Path, - public_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any]: ledger = CleanupLedger(ledger_path) try: @@ -629,13 +634,13 @@ async def _publish_cleanup_resource_changes( def _cleanup_event_type_for_status(status: str) -> str | None: if status == "started": - return "cleanup_started" + return PIPELINE_EVENT_CLEANUP_STARTED if status == "in_progress": - return "cleanup_progress" + return PIPELINE_EVENT_CLEANUP_PROGRESS if status == "completed": - return "cleanup_completed" + return PIPELINE_EVENT_CLEANUP_COMPLETED if status == "failed": - return "cleanup_failed" + return PIPELINE_EVENT_CLEANUP_FAILED return None @@ -1298,7 +1303,6 @@ async def _ensure_pipeline_handoff_context_in_session(self, *, context_id: str, 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), - public_snapshot=snapshot, ) 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 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/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index 9f386ba7..ff011887 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -338,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" @@ -962,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 b40877be..eef50b42 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -11,7 +11,6 @@ import httpx from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent from a2a.utils.errors import InvalidParamsError -from jsonrpc.jsonrpc2 import JSONRPC20Response from iac_code.a2a.events import make_text_part from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator @@ -76,37 +75,6 @@ def _auth_error_text() -> str: return _("Authentication required. Configure credentials and retry.") -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) - - -_install_jsonrpc_error_data_passthrough() - - class RecoverablePipelineInvalidParamsError(InvalidParamsError): code = -32602 jsonrpc_error_data_passthrough = True diff --git a/src/iac_code/a2a/pipeline_snapshot.py b/src/iac_code/a2a/pipeline_snapshot.py index 0d09de19..6d54b69e 100644 --- a/src/iac_code/a2a/pipeline_snapshot.py +++ b/src/iac_code/a2a/pipeline_snapshot.py @@ -12,6 +12,12 @@ 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 @@ -25,10 +31,10 @@ "pipeline_canceled": "canceled", } _CLEANUP_STATUS_BY_EVENT_TYPE = { - "cleanup_started": "started", - "cleanup_progress": "in_progress", - "cleanup_completed": "completed", - "cleanup_failed": "failed", + 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 = { @@ -40,6 +46,7 @@ "lastError", "last_error", } +_PIPELINE_WARNING_PRIVATE_DATA_KEYS = {"ledger_path", "ledgerPath", "load_error", "loadError"} class A2APipelineSnapshotStore: @@ -128,6 +135,10 @@ def sanitize_pipeline_cleanup_private_fields(value: Any) -> Any: 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 @@ -150,6 +161,7 @@ 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 @@ -201,6 +213,7 @@ 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() @@ -409,6 +422,12 @@ def _apply(self, event: dict[str, Any]) -> None: 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) @@ -972,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"))) @@ -1062,6 +1098,7 @@ def _empty_snapshot() -> dict[str, Any]: "rollbackHistory": [], "candidateRestarts": [], "handoffHistory": [], + "warningHistory": [], }, "seenEventIds": [], } @@ -1157,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 [] @@ -1186,6 +1224,19 @@ def _sanitize_public_snapshot_private_cleanup_fields(value: dict[str, Any]) -> d 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 diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index 63f8acf0..8ad950fd 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -15,6 +15,12 @@ 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]] @@ -38,10 +44,11 @@ "pipeline_failed", "pipeline_canceled", "pipeline_handoff_ready", - "cleanup_started", - "cleanup_progress", - "cleanup_completed", - "cleanup_failed", + "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", @@ -54,7 +61,7 @@ "tool_result", } _RECOVERY_STATE_SCOPES = {"step", "candidate", "candidateStep", "candidate_step"} -_RECOVERY_STATE_STATUSES = {"working", "waiting_input", "input_required", "completed", "failed", "canceled"} +_RECOVERY_STATE_STATUSES = {"working"} class _SnapshotCatchUpUnavailableError(Exception): diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index c30c3cf2..714c8079 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -44,11 +44,15 @@ from starlette.routing import Route from iac_code.a2a.agent_card import build_agent_card, build_extended_agent_card -from iac_code.a2a.app import install_v03_jsonrpc_error_data_passthrough, normalize_v03_jsonrpc_version +from iac_code.a2a.app import normalize_v03_jsonrpc_version from iac_code.a2a.artifacts import A2AArtifactStore 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 ( @@ -520,6 +524,7 @@ 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) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 8a92e38c..89b73ccd 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -1323,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/pipeline/engine/cleanup.py b/src/iac_code/pipeline/engine/cleanup.py index 469356d7..3e19a637 100644 --- a/src/iac_code/pipeline/engine/cleanup.py +++ b/src/iac_code/pipeline/engine/cleanup.py @@ -4,7 +4,6 @@ import json import logging -import threading import time from dataclasses import asdict, dataclass, field, replace from pathlib import Path @@ -14,8 +13,15 @@ from iac_code.agent.message import Message from iac_code.i18n import _ -from iac_code.pipeline.constants import CLEANUP_PROMPT_METADATA_TYPE +from iac_code.pipeline.constants import ( + CLEANUP_PROMPT_METADATA_TYPE, + PIPELINE_EVENT_CLEANUP_COMPLETED, + PIPELINE_EVENT_CLEANUP_FAILED, + PIPELINE_EVENT_CLEANUP_PROGRESS, + PIPELINE_EVENT_CLEANUP_STARTED, +) from iac_code.types.stream_events import StackProgressEvent, ToolResultEvent, ToolUseEndEvent +from iac_code.utils.path_locks import PathLockRegistry from iac_code.utils.public_errors import sanitize_public_text from iac_code.utils.state_io import atomic_write_text @@ -30,12 +36,13 @@ _TERMINAL_CLEANUP_STATUSES = {"completed", "skipped"} _DELETE_COMPLETE_STATUSES = {"DELETE_COMPLETE"} _DELETE_FAILED_STATUSES = {"DELETE_FAILED"} -_LEDGER_LOCKS: dict[Path, threading.RLock] = {} -_LEDGER_LOCKS_LOCK = threading.Lock() +_LEDGER_LOCKS = PathLockRegistry() @dataclass(frozen=True) class ObservedResource: + """Cloud resource observed during a side-effecting pipeline step.""" + provider: str resource_type: str resource_id: str @@ -69,6 +76,8 @@ def from_dict(cls, data: dict[str, Any]) -> "ObservedResource": @dataclass(frozen=True) class CleanupResource: + """Resource that may need cleanup after rollback or handoff.""" + provider: str resource_type: str resource_id: str @@ -138,12 +147,36 @@ def from_dict(cls, data: dict[str, Any]) -> "CleanupResource": @dataclass(frozen=True) class CleanupPrompt: + """Hidden user message content that constrains cleanup to ledger resources.""" + resources: list[CleanupResource] prompt: str status_message: str +@dataclass(frozen=True) +class CleanupLedgerWriteStatus: + """Result of a cleanup ledger write attempt.""" + + written: bool + unavailable: bool = False + reason: str | None = None + load_error: str | None = None + + +_WRITE_SKIPPED = CleanupLedgerWriteStatus(written=False) +_WRITE_OK = CleanupLedgerWriteStatus(written=True) + + class CleanupLedger: + """Durable cleanup state for rollback leftovers. + + The ledger is fail-closed for writes: corrupt or unreadable files are never + replaced with empty state. Key write methods return + `CleanupLedgerWriteStatus` so callers can surface unavailable cleanup + tracking without broadening cleanup scope. + """ + def __init__(self, path: str | Path) -> None: self.path = Path(path) @@ -186,13 +219,20 @@ def active_resources(self) -> list[CleanupResource]: if resource.cleanup_required and resource.cleanup_status in _ACTIVE_CLEANUP_STATUSES ] - def record_observed(self, resource: ObservedResource) -> None: + def record_observed(self, resource: ObservedResource) -> CleanupLedgerWriteStatus: + """Persist an observed resource and report whether the write happened.""" + if not resource.resource_id: - return + return _WRITE_SKIPPED with self._write_lock(): data = self._load_for_write() if data is None: - return + return CleanupLedgerWriteStatus( + written=False, + unavailable=True, + reason="load_failed", + load_error=self.load_error(), + ) observed = { ObservedResource.from_dict(item).key: ObservedResource.from_dict(item) for item in _dict_list(data.get("observed_resources")) @@ -200,6 +240,7 @@ def record_observed(self, resource: ObservedResource) -> None: observed[resource.key] = resource data["observed_resources"] = [asdict(item) for item in observed.values()] self._save(data) + return _WRITE_OK def mark_cleanup_required( self, @@ -207,13 +248,20 @@ def mark_cleanup_required( *, source_step_id: str, reason: str, - ) -> None: + ) -> CleanupLedgerWriteStatus: + """Mark resources for cleanup after rollback without overwriting corrupt state.""" + if not resources: - return + return _WRITE_SKIPPED with self._write_lock(): data = self._load_for_write() if data is None: - return + return CleanupLedgerWriteStatus( + written=False, + unavailable=True, + reason="load_failed", + load_error=self.load_error(), + ) cleanup = { CleanupResource.from_dict(item).key: CleanupResource.from_dict(item) for item in _dict_list(data.get("cleanup_resources")) @@ -236,7 +284,7 @@ def mark_cleanup_required( cleanup[resource.key] = merged changed_count += 1 if changed_count == 0: - return + return _WRITE_SKIPPED data["cleanup_resources"] = [asdict(item) for item in cleanup.values()] self._append_history( data, @@ -249,6 +297,7 @@ def mark_cleanup_required( }, ) self._save(data) + return _WRITE_OK def update_resource( self, @@ -524,11 +573,13 @@ def _append_history(data: dict[str, Any], entry: dict[str, Any]) -> None: if isinstance(history, list): history.append(entry) - def _write_lock(self) -> threading.RLock: + def _write_lock(self): return _ledger_path_lock(self.path) class CleanupObserver: + """Observe tool calls/results and update cleanup resource lifecycle state.""" + def __init__(self, ledger: CleanupLedger) -> None: self._ledger = ledger self._tool_inputs: dict[str, dict[str, Any]] = {} @@ -739,14 +790,8 @@ def mark_cleanup_prompt_message_completed(message: Message, *, cleanup_ledger_pa return True -def _ledger_path_lock(path: Path) -> threading.RLock: - resolved = path.resolve() - with _LEDGER_LOCKS_LOCK: - lock = _LEDGER_LOCKS.get(resolved) - if lock is None: - lock = threading.RLock() - _LEDGER_LOCKS[resolved] = lock - return lock +def _ledger_path_lock(path: Path): + return _LEDGER_LOCKS.lock_for(path) def _merge_cleanup_required( @@ -888,13 +933,13 @@ def _cleanup_lifecycle_state(resource: CleanupResource) -> tuple[Any, ...]: def _cleanup_lifecycle_history_entry(resource: CleanupResource) -> dict[str, Any]: event_type = { - "started": "cleanup_started", - "in_progress": "cleanup_progress", - "completed": "cleanup_completed", - "failed": "cleanup_failed", + "started": PIPELINE_EVENT_CLEANUP_STARTED, + "in_progress": PIPELINE_EVENT_CLEANUP_PROGRESS, + "completed": PIPELINE_EVENT_CLEANUP_COMPLETED, + "failed": PIPELINE_EVENT_CLEANUP_FAILED, "skipped": "cleanup_skipped", "pending": "cleanup_pending", - }.get(resource.cleanup_status, "cleanup_progress") + }.get(resource.cleanup_status, PIPELINE_EVENT_CLEANUP_PROGRESS) entry = { "type": event_type, "resource": _cleanup_resource_history_data(resource), diff --git a/src/iac_code/pipeline/engine/events.py b/src/iac_code/pipeline/engine/events.py index fc90116b..8999fc78 100644 --- a/src/iac_code/pipeline/engine/events.py +++ b/src/iac_code/pipeline/engine/events.py @@ -11,6 +11,7 @@ class PipelineEventType(str, Enum): PIPELINE_COMPLETED = "pipeline_completed" PIPELINE_RESUMED = "pipeline_resumed" PIPELINE_ERROR = "pipeline_error" + PIPELINE_WARNING = "pipeline_warning" STEP_STARTED = "step_started" STEP_COMPLETED = "step_completed" diff --git a/src/iac_code/pipeline/engine/pipeline_runner.py b/src/iac_code/pipeline/engine/pipeline_runner.py index 78a35b78..5e8d7a6d 100644 --- a/src/iac_code/pipeline/engine/pipeline_runner.py +++ b/src/iac_code/pipeline/engine/pipeline_runner.py @@ -16,7 +16,12 @@ from iac_code.agent.message import ContentBlock, Message, ToolResultBlock from iac_code.i18n import _ -from iac_code.pipeline.engine.cleanup import CleanupLedger, CleanupResource, ObservedResource +from iac_code.pipeline.engine.cleanup import ( + CleanupLedger, + CleanupLedgerWriteStatus, + CleanupResource, + ObservedResource, +) from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.display_replay import DISPLAY_TRANSCRIPT_FILENAME from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType @@ -504,11 +509,11 @@ def _handle_resource_observed( event: ResourceObservedEvent, *, attempt_id: str | None, - ) -> None: + ) -> list[PipelineEvent]: hook = getattr(step, "on_resource_observed", None) ledger = self.cleanup_ledger() if ledger is None or not callable(hook): - return + return [] try: result = hook( self.context, @@ -519,10 +524,11 @@ def _handle_resource_observed( ) except Exception: logger.warning("Pipeline resource-observed hook failed: step_id=%s", step.step_id, exc_info=True) - return + return [] + events: list[PipelineEvent] = [] for observed in self._observed_resources_from_hook_result(result): try: - ledger.record_observed(observed) + status = ledger.record_observed(observed) except Exception as exc: logger.warning( "Failed to persist observed cleanup resource: step_id=%s resource_id=%s error=%s", @@ -535,6 +541,17 @@ def _handle_resource_observed( "pipeline state persistence failed during record_observed_cleanup_resource", step_id=step.step_id, ) from exc + if status.unavailable: + events.append( + self._cleanup_tracking_unavailable_event( + step_id=step.step_id, + operation="record_observed", + ledger=ledger, + status=status, + resource_id=observed.resource_id, + ) + ) + return events def _mark_rollback_cleanup_required( self, @@ -543,11 +560,11 @@ def _mark_rollback_cleanup_required( reason: str, *, from_attempt_id: str | None, - ) -> None: + ) -> list[PipelineEvent]: hook = getattr(step, "on_rollback_cleanup_required", None) ledger = self.cleanup_ledger() if ledger is None or not callable(hook): - return + return [] try: result = hook( self.context, @@ -559,11 +576,11 @@ def _mark_rollback_cleanup_required( ) except Exception: logger.warning("Pipeline rollback cleanup hook failed: step_id=%s", step.step_id, exc_info=True) - return + return [] resources = self._cleanup_resources_from_hook_result(result) if resources: try: - ledger.mark_cleanup_required(resources, source_step_id=step.step_id, reason=reason) + status = ledger.mark_cleanup_required(resources, source_step_id=step.step_id, reason=reason) except Exception as exc: logger.warning( "Failed to persist rollback cleanup resources: step_id=%s target_step_id=%s error=%s", @@ -576,6 +593,49 @@ def _mark_rollback_cleanup_required( "pipeline state persistence failed during mark_rollback_cleanup_required", step_id=step.step_id, ) from exc + if status.unavailable: + return [ + self._cleanup_tracking_unavailable_event( + step_id=step.step_id, + operation="mark_cleanup_required", + ledger=ledger, + status=status, + resource_count=len(resources), + ) + ] + return [] + + def _cleanup_tracking_unavailable_event( + self, + *, + step_id: str, + operation: str, + ledger: CleanupLedger, + status: CleanupLedgerWriteStatus, + resource_id: str | None = None, + resource_count: int | None = None, + ) -> PipelineEvent: + data: dict[str, Any] = { + "reason": "cleanup_tracking_unavailable", + "operation": operation, + } + if resource_id: + data["resource_id"] = resource_id + if resource_count is not None: + data["resource_count"] = resource_count + logger.warning( + "Pipeline cleanup tracking unavailable: step_id=%s operation=%s ledger_path=%s error=%s", + step_id, + operation, + ledger.path, + status.load_error, + ) + return PipelineEvent( + type=PipelineEventType.PIPELINE_WARNING, + step_id=step_id, + timestamp=time.time(), + data=data, + ) @staticmethod def _observed_resources_from_hook_result(result: object) -> list[ObservedResource]: @@ -3123,11 +3183,12 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: else: if isinstance(event, ResourceObservedEvent): try: - self._handle_resource_observed( + for warning_event in self._handle_resource_observed( step, event, attempt_id=attempt.get("attempt_id"), - ) + ): + yield warning_event except PipelineStatePersistenceError as exc: yield self._persistence_failure_event(exc) return @@ -3335,12 +3396,13 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> stale = self.context.mark_stale(target_field) if target_field else [] self._set_current_step_user_input(None) try: - self._mark_rollback_cleanup_required( + for warning_event in self._mark_rollback_cleanup_required( step, target, reason, from_attempt_id=current_attempt_id if isinstance(current_attempt_id, str) else None, - ) + ): + yield warning_event except PipelineStatePersistenceError as exc: yield self._persistence_failure_event(exc) return diff --git a/src/iac_code/tools/path_safety.py b/src/iac_code/tools/path_safety.py index 85104822..8270f3ba 100644 --- a/src/iac_code/tools/path_safety.py +++ b/src/iac_code/tools/path_safety.py @@ -4,6 +4,7 @@ import os import sys +import tempfile from collections.abc import Iterator, Sequence from dataclasses import dataclass from pathlib import Path @@ -79,7 +80,7 @@ def _normalize_for_platform(path: str, *, case_insensitive: bool | None = None) if case_insensitive is None: case_insensitive = sys.platform == "win32" if case_insensitive: - return normalized.lower() + return normalized.casefold() return normalized @@ -158,13 +159,52 @@ def get_iac_code_application_root() -> Path: def _path_is_under(path: str, root: str) -> bool: - path_r = _normalize_for_platform(os.path.realpath(path)) - root_r = _normalize_for_platform(os.path.realpath(root)) + root_real_raw = os.path.realpath(root) + case_insensitive = _should_casefold_for_under_check(root_real_raw) + path_r = _normalize_for_platform(os.path.realpath(path), case_insensitive=case_insensitive) + root_r = _normalize_for_platform(root_real_raw, case_insensitive=case_insensitive) if path_r == root_r: return True return path_r.startswith(root_r.rstrip("/") + "/") +def _should_casefold_for_under_check(root: str) -> bool: + if sys.platform == "win32": + return True + if sys.platform == "darwin": + return not _path_case_sensitive(root) + return False + + +def _path_case_sensitive(root: str) -> bool: + probe_dir = _existing_probe_dir(root) + if probe_dir is None: + return True + try: + fd, probe_path = tempfile.mkstemp(prefix=".iac-code-case-", dir=probe_dir) + except OSError: + return True + os.close(fd) + alternate = os.path.join(probe_dir, os.path.basename(probe_path).swapcase()) + try: + return not os.path.exists(alternate) + finally: + try: + os.unlink(probe_path) + except OSError: + pass + + +def _existing_probe_dir(path: str) -> str | None: + candidate = path if os.path.isdir(path) else os.path.dirname(path) + while candidate and not os.path.isdir(candidate): + parent = os.path.dirname(candidate) + if parent == candidate: + return None + candidate = parent + return candidate or None + + def _is_in_allowed_roots(path: str, roots: list[str]) -> bool: return any(_path_is_under(path, root) for root in roots if root) diff --git a/src/iac_code/tools/read_file.py b/src/iac_code/tools/read_file.py index 070eb166..51ba7a68 100644 --- a/src/iac_code/tools/read_file.py +++ b/src/iac_code/tools/read_file.py @@ -4,33 +4,17 @@ import codecs import os -import sys from typing import Any from iac_code.i18n import _ from iac_code.tools.base import Tool, ToolContext, ToolResult -from iac_code.tools.path_safety import check_read_path, resolve_candidate +from iac_code.tools.path_safety import _path_is_under, check_read_path, resolve_candidate from iac_code.types.permissions import PermissionDecisionReason, PermissionResult, ToolPermissionContext MAX_READ_BYTES = 10 * 1024 * 1024 MAX_READ_LINES = 50_000 -def _path_is_under(path: str, root: str) -> bool: - path_real = _normalize_for_under_check(os.path.realpath(path)) - root_real = _normalize_for_under_check(os.path.realpath(root)) - if path_real == root_real: - return True - return path_real.startswith(root_real.rstrip("/") + "/") - - -def _normalize_for_under_check(path: str) -> str: - normalized = path.replace("\\", "/") - if sys.platform == "win32": - return normalized.lower() - return normalized - - def _resolve_input_path( path: str, cwd: str, diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index 0bcd0955..f5e7630c 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -1828,6 +1828,12 @@ def _cleanup_resume_should_show_detail(resource) -> bool: @staticmethod def _cleanup_resume_history_resources(ledger) -> list[Any]: + 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 CleanupResource get_history = getattr(ledger, "history_entries", None) @@ -1837,10 +1843,10 @@ def _cleanup_resume_history_resources(ledger) -> list[Any]: for entry in get_history(): event_type = str(entry.get("type") or "") if event_type not in { - "cleanup_started", - "cleanup_progress", - "cleanup_completed", - "cleanup_failed", + PIPELINE_EVENT_CLEANUP_STARTED, + PIPELINE_EVENT_CLEANUP_PROGRESS, + PIPELINE_EVENT_CLEANUP_COMPLETED, + PIPELINE_EVENT_CLEANUP_FAILED, "cleanup_skipped", "cleanup_pending", }: @@ -4009,6 +4015,10 @@ def _render_pipeline_event(self, event): err = event.data.get("error", "") step_id = event.step_id or "" con.print(f" [red]✗ {display_step_name(step_id)}[/] [dim]── {err}[/]") + case PipelineEventType.PIPELINE_WARNING: + reason = str(event.data.get("reason") or "warning") + message = str(event.data.get("message") or _("Pipeline warning: {reason}").format(reason=reason)) + con.print(f" [yellow]⚠[/] [yellow]{message}[/]") case PipelineEventType.USER_INPUT_REQUIRED: options = event.data.get("options", []) prompt_text = event.data.get("prompt", "") diff --git a/src/iac_code/utils/path_locks.py b/src/iac_code/utils/path_locks.py new file mode 100644 index 00000000..4abcca00 --- /dev/null +++ b/src/iac_code/utils/path_locks.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import threading +import weakref +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + + +class PathLockRegistry: + """Weak per-path RLock registry that preserves uniqueness for live locks.""" + + def __init__(self) -> None: + self._locks: weakref.WeakValueDictionary[Path, threading.RLock] = weakref.WeakValueDictionary() + self._guard = threading.Lock() + + @contextmanager + def lock_for(self, path: str | Path) -> Iterator[threading.RLock]: + lock = self._get_lock(Path(path)) + with lock: + yield lock + + def prune(self) -> None: + with self._guard: + # Touching WeakValueDictionary materializes pending removals. + list(self._locks.items()) + + def _get_lock(self, path: Path) -> threading.RLock: + resolved = path.resolve() + with self._guard: + lock = self._locks.get(resolved) + if lock is None: + lock = threading.RLock() + self._locks[resolved] = lock + return lock diff --git a/src/iac_code/utils/state_io.py b/src/iac_code/utils/state_io.py index 6f8cd243..bfcc271c 100644 --- a/src/iac_code/utils/state_io.py +++ b/src/iac_code/utils/state_io.py @@ -4,27 +4,22 @@ import json import os +import shutil import sys import tempfile -import threading import time from collections.abc import Callable, Iterable -from contextlib import contextmanager +from contextlib import contextmanager, suppress from pathlib import Path from typing import Any, Iterator -_PATH_LOCKS: dict[Path, threading.RLock] = {} -_PATH_LOCKS_LOCK = threading.Lock() +from iac_code.utils.path_locks import PathLockRegistry +_PATH_LOCKS = PathLockRegistry() -def _path_lock(path: Path) -> threading.RLock: - resolved = path.resolve() - with _PATH_LOCKS_LOCK: - lock = _PATH_LOCKS.get(resolved) - if lock is None: - lock = threading.RLock() - _PATH_LOCKS[resolved] = lock - return lock + +def _path_lock(path: Path): + return _PATH_LOCKS.lock_for(path) def safe_replace(src: str | Path, dst: str | Path, *, attempts: int = 3, delay: float = 0.05) -> None: @@ -38,6 +33,37 @@ def safe_replace(src: str | Path, dst: str | Path, *, attempts: int = 3, delay: if attempt >= attempts - 1: raise time.sleep(delay * (attempt + 1)) + except OSError as exc: + if exc.errno != getattr(os, "EXDEV", 18): + raise + _copy_replace_across_devices(Path(src), Path(dst), attempts=attempts, delay=delay) + return + + +def _copy_replace_across_devices(src: Path, dst: Path, *, attempts: int, delay: float) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + handle = tempfile.NamedTemporaryFile( + prefix=f".{dst.name}.", + suffix=".tmp", + dir=dst.parent, + delete=False, + ) + tmp_path = Path(handle.name) + handle.close() + try: + shutil.copy2(src, tmp_path) + try: + with tmp_path.open("rb") as handle: + os.fsync(handle.fileno()) + except OSError: + pass + safe_replace(tmp_path, dst, attempts=attempts, delay=delay) + fsync_parent_dir(dst) + src.unlink() + except Exception: + with suppress(OSError): + tmp_path.unlink() + raise def fsync_parent_dir(path: Path) -> None: diff --git a/tests/a2a/test_executor_cleanup.py b/tests/a2a/test_executor_cleanup.py index 77df57c6..f2873600 100644 --- a/tests/a2a/test_executor_cleanup.py +++ b/tests/a2a/test_executor_cleanup.py @@ -53,16 +53,12 @@ async def get_context_record(self, context_id: str) -> SimpleNamespace: def test_a2a_handoff_does_not_reconstruct_cleanup_prompt_from_public_snapshot(tmp_path: Path) -> None: - snapshot = { - "cleanup": { - "resources": [{"provider": "ros", "resourceId": "stack-123", "resourceType": "stack"}], - "status": "pending", - } - } + import inspect + + assert "public_snapshot" not in inspect.signature(_cleanup_payload_from_private_ledger_or_unavailable).parameters cleanup = _cleanup_payload_from_private_ledger_or_unavailable( ledger_path=tmp_path / "missing-cleanup.yaml", - public_snapshot=snapshot, ) assert cleanup["status"] == "unavailable" diff --git a/tests/a2a/test_jsonrpc_passthrough.py b/tests/a2a/test_jsonrpc_passthrough.py new file mode 100644 index 00000000..0d5f0fbe --- /dev/null +++ b/tests/a2a/test_jsonrpc_passthrough.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import importlib +from typing import Any + + +def test_importing_pipeline_executor_does_not_install_jsonrpc_passthrough(monkeypatch) -> None: + from a2a.server.request_handlers import response_helpers + from a2a.server.routes import jsonrpc_dispatcher + + def sentinel_build_error_response(request_id: str | int | None, error: Any) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": getattr(error, "code", -32603)}} + + monkeypatch.setattr(response_helpers, "build_error_response", sentinel_build_error_response) + monkeypatch.setattr(jsonrpc_dispatcher, "build_error_response", sentinel_build_error_response) + + import iac_code.a2a.pipeline_executor as pipeline_executor + + importlib.reload(pipeline_executor) + + assert response_helpers.build_error_response is sentinel_build_error_response + assert jsonrpc_dispatcher.build_error_response is sentinel_build_error_response + + +def test_jsonrpc_passthrough_explicit_install_is_idempotent(monkeypatch) -> None: + from a2a.server.request_handlers import response_helpers + from a2a.server.routes import jsonrpc_dispatcher + + from iac_code.a2a.jsonrpc_passthrough import install_jsonrpc_error_data_passthrough + + def original_build_error_response(request_id: str | int | None, error: Any) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": getattr(error, "code", -32603)}} + + class RecoverableError(Exception): + code = -32602 + jsonrpc_error_data_passthrough = True + data = {"recoverableTaskId": "task-owner"} + + monkeypatch.setattr(response_helpers, "build_error_response", original_build_error_response) + monkeypatch.setattr(jsonrpc_dispatcher, "build_error_response", original_build_error_response) + + install_jsonrpc_error_data_passthrough() + installed = response_helpers.build_error_response + install_jsonrpc_error_data_passthrough() + + assert response_helpers.build_error_response is installed + assert jsonrpc_dispatcher.build_error_response is installed + response = installed("req-1", RecoverableError("Pipeline already running")) + assert response["error"]["code"] == -32602 + assert response["error"]["data"] == {"recoverableTaskId": "task-owner"} diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py index a5ca62c2..306b64ef 100644 --- a/tests/a2a/test_pipeline_events.py +++ b/tests/a2a/test_pipeline_events.py @@ -58,6 +58,31 @@ 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()) diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index 21fef6a2..a7f90725 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -50,10 +50,12 @@ def test_active_sidecar_mismatch_error_exposes_jsonrpc_data() -> None: def test_active_sidecar_mismatch_error_serializes_raw_jsonrpc_data() -> None: - from a2a.server.request_handlers.response_helpers import build_error_response - + 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", @@ -2281,6 +2283,35 @@ def test_cleanup_handoff_missing_ledger_ignores_empty_public_cleanup_snapshot(tm 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 diff --git a/tests/a2a/test_pipeline_recovery.py b/tests/a2a/test_pipeline_recovery.py index fc40197d..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") diff --git a/tests/a2a/test_pipeline_snapshot.py b/tests/a2a/test_pipeline_snapshot.py index 46f183f8..30ff10f2 100644 --- a/tests/a2a/test_pipeline_snapshot.py +++ b/tests/a2a/test_pipeline_snapshot.py @@ -91,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"] = { diff --git a/tests/a2a/test_pipeline_stream.py b/tests/a2a/test_pipeline_stream.py index 59ecb57f..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) diff --git a/tests/a2a/test_selling_console_script.py b/tests/a2a/test_selling_console_script.py index a1a19d03..9c160a4a 100644 --- a/tests/a2a/test_selling_console_script.py +++ b/tests/a2a/test_selling_console_script.py @@ -177,6 +177,23 @@ def close(self) -> None: 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") diff --git a/tests/agent/test_agent_loop_continue.py b/tests/agent/test_agent_loop_continue.py index 805ef5e7..cfb5e8dd 100644 --- a/tests/agent/test_agent_loop_continue.py +++ b/tests/agent/test_agent_loop_continue.py @@ -56,7 +56,7 @@ async def test_continue_streaming_uses_existing_context_without_appending_user_m assert appended_roles == ["assistant"] -def test_stamp_last_turn_elapsed_does_not_request_cleanup_prompt_preservation(): +def test_stamp_last_turn_elapsed_preserves_cleanup_prompts(): storage = RecordingStorage() loop = AgentLoop( provider_manager=FakeProviderManager(), @@ -74,4 +74,4 @@ def test_stamp_last_turn_elapsed_does_not_request_cleanup_prompt_preservation(): _cwd, _session_id, messages, _branch, preserve_cleanup_prompts = storage.saved[0] assert [message.content for message in messages] == ["later", "done"] assert messages[-1].elapsed_seconds == 1.5 - assert preserve_cleanup_prompts is False + assert preserve_cleanup_prompts is True diff --git a/tests/pipeline/engine/test_cleanup.py b/tests/pipeline/engine/test_cleanup.py index afeef051..ad727ddf 100644 --- a/tests/pipeline/engine/test_cleanup.py +++ b/tests/pipeline/engine/test_cleanup.py @@ -669,7 +669,7 @@ def test_corrupt_ledger_records_unavailable_without_overwrite(tmp_path) -> None: path.write_text("[broken", encoding="utf-8") ledger = CleanupLedger(path) - ledger.mark_cleanup_required( + status = ledger.mark_cleanup_required( [CleanupResource.from_observed(_observed_stack(), reason="rollback")], source_step_id="deploying", reason="rollback", @@ -678,6 +678,24 @@ def test_corrupt_ledger_records_unavailable_without_overwrite(tmp_path) -> None: assert path.read_text(encoding="utf-8") == "[broken" assert ledger.load_failed() assert ledger.load_error() + assert status.written is False + assert status.unavailable is True + assert status.reason == "load_failed" + assert status.load_error + + +def test_corrupt_ledger_record_observed_reports_unavailable_without_overwrite(tmp_path) -> None: + path = tmp_path / "cleanup.yaml" + path.write_text("[broken", encoding="utf-8") + ledger = CleanupLedger(path) + + status = ledger.record_observed(_observed_stack()) + + assert path.read_text(encoding="utf-8") == "[broken" + assert status.written is False + assert status.unavailable is True + assert status.reason == "load_failed" + assert status.load_error def test_cleanup_ledger_save_uses_state_io_atomic_durable_write(tmp_path, monkeypatch) -> None: diff --git a/tests/pipeline/engine/test_display_replay.py b/tests/pipeline/engine/test_display_replay.py index 3b88d57b..1a4eab1a 100644 --- a/tests/pipeline/engine/test_display_replay.py +++ b/tests/pipeline/engine/test_display_replay.py @@ -32,6 +32,26 @@ def test_recorder_and_reducer_preserve_repeated_attempts_after_rollback(tmp_path assert model.interrupted is True +def test_display_replay_ignores_pipeline_warning_without_terminal_change(tmp_path) -> None: + path = tmp_path / "display.jsonl" + recorder = PipelineDisplayRecorder(path) + + recorder.record("pipeline_started", pipeline_name="selling", timestamp=1.0) + recorder.record("step_started", step_id="deploying", payload={"index": 1, "total": 1}, timestamp=1.5) + recorder.record( + "pipeline_warning", + step_id="deploying", + payload={"reason": "cleanup_tracking_unavailable"}, + timestamp=2.0, + ) + + model = PipelineDisplayReducer().reduce(load_display_events(path)) + + assert model.interrupted is False + assert model.failed is False + assert model.attempts[-1].status == "running" + + def test_reducer_attaches_transcript_ids_from_event_payload_and_attempt_metadata(tmp_path): path = tmp_path / "display.jsonl" recorder = PipelineDisplayRecorder(path) diff --git a/tests/pipeline/engine/test_pipeline_runner_cleanup.py b/tests/pipeline/engine/test_pipeline_runner_cleanup.py index 96786b07..3463d891 100644 --- a/tests/pipeline/engine/test_pipeline_runner_cleanup.py +++ b/tests/pipeline/engine/test_pipeline_runner_cleanup.py @@ -7,6 +7,7 @@ from iac_code.pipeline.engine.cleanup import CleanupLedger, CleanupResource, ObservedResource from iac_code.pipeline.engine.context import PipelineContext +from iac_code.pipeline.engine.events import PipelineEventType from iac_code.pipeline.engine.pipeline_runner import PipelineRunner, PipelineStatePersistenceError from iac_code.pipeline.engine.session import PipelineSession from iac_code.pipeline.engine.step_spec import LoadedPipeline, StepSpec @@ -205,3 +206,103 @@ def fail_mark_cleanup_required(self, resources, *, source_step_id, reason): assert "Failed to persist rollback cleanup resources" in caplog.text assert "step_id=deploying" in caplog.text assert "cleanup disk full" in caplog.text + + +def test_runner_emits_warning_event_when_observed_cleanup_ledger_unavailable(tmp_path, caplog) -> None: + runner = _runner(tmp_path) + ledger_path = runner.session.session_dir / "cleanup.yaml" + ledger_path.parent.mkdir(parents=True, exist_ok=True) + ledger_path.write_text("[broken", encoding="utf-8") + + def on_resource_observed(ctx, event, *, ledger, step_id, attempt_id): + return ObservedResource( + provider=event.provider, + resource_type=event.resource_type, + resource_id=event.resource_id, + resource_name=event.resource_name, + region_id=event.region_id, + source_step_id=step_id, + source_attempt_id=attempt_id, + observed_action=event.action, + ) + + step = StepSpec( + step_id="deploying", + conclusion_field="deployment", + forward=None, + prompt_file="deploying.md", + ) + step.on_resource_observed = on_resource_observed + caplog.set_level(logging.WARNING, logger="iac_code.pipeline.engine.pipeline_runner") + + events = runner._handle_resource_observed( + step, + ResourceObservedEvent( + provider="ros", + resource_type="stack", + resource_id="stack-123", + resource_name="demo", + region_id="cn-hangzhou", + action="CreateStack", + ), + attempt_id="att_0001", + ) + + assert ledger_path.read_text(encoding="utf-8") == "[broken" + assert len(events) == 1 + event = events[0] + assert event.type == PipelineEventType.PIPELINE_WARNING + assert event.step_id == "deploying" + assert event.data["reason"] == "cleanup_tracking_unavailable" + assert event.data["operation"] == "record_observed" + assert event.data["resource_id"] == "stack-123" + assert "ledger_path" not in event.data + assert "load_error" not in event.data + assert "cleanup tracking unavailable" in caplog.text.lower() + + +def test_runner_emits_warning_event_when_required_cleanup_ledger_unavailable(tmp_path, caplog) -> None: + runner = _runner(tmp_path) + ledger_path = runner.session.session_dir / "cleanup.yaml" + ledger_path.parent.mkdir(parents=True, exist_ok=True) + ledger_path.write_text("[broken", encoding="utf-8") + + def on_rollback_cleanup_required(ctx, *, ledger, from_step, from_attempt_id, to_step, reason): + return [ + CleanupResource( + provider="ros", + resource_type="stack", + resource_id="stack-123", + source_step_id=from_step, + source_attempt_id=from_attempt_id, + cleanup_reason=reason, + ) + ] + + step = StepSpec( + step_id="deploying", + conclusion_field="deployment", + forward=None, + prompt_file="deploying.md", + ) + step.on_rollback_cleanup_required = on_rollback_cleanup_required + caplog.set_level(logging.WARNING, logger="iac_code.pipeline.engine.pipeline_runner") + + events = runner._mark_rollback_cleanup_required( + step, + "confirm_and_select", + "invalid selection", + from_attempt_id="att_0001", + ) + + assert ledger_path.read_text(encoding="utf-8") == "[broken" + assert len(events) == 1 + event = events[0] + assert event.type == PipelineEventType.PIPELINE_WARNING + assert event.step_id == "deploying" + assert event.data["reason"] == "cleanup_tracking_unavailable" + assert event.data["operation"] == "mark_cleanup_required" + assert event.data["resource_count"] == 1 + assert "ledger_path" not in event.data + assert "load_error" not in event.data + assert "cleanup tracking unavailable" in caplog.text.lower() diff --git a/tests/tools/test_path_safety.py b/tests/tools/test_path_safety.py index 3f4f90fc..a9051e54 100644 --- a/tests/tools/test_path_safety.py +++ b/tests/tools/test_path_safety.py @@ -147,3 +147,23 @@ def test_windows_root_containment_is_case_insensitive(monkeypatch, tmp_path): child = root / "src" / "app.py" assert _path_is_under(str(child).upper(), str(root).lower()) + + +def test_macos_case_insensitive_root_containment_allows_differently_cased_project_path(monkeypatch, tmp_path): + import iac_code.tools.path_safety as path_safety + + monkeypatch.setattr(path_safety.sys, "platform", "darwin") + monkeypatch.setattr(path_safety, "_path_case_sensitive", lambda _root: False, raising=False) + cwd = tmp_path / "Project" + child = cwd / "src" / "app.py" + child.parent.mkdir(parents=True) + child.write_text("print('ok')", encoding="utf-8") + + result = check_read_path( + str(child).upper(), + cwd=str(cwd).lower(), + additional_directories=[], + trusted_read_directories=[], + ) + + assert result.behavior == "allow" diff --git a/tests/tools/test_read_file.py b/tests/tools/test_read_file.py index 0bc4fad3..b4871c6d 100644 --- a/tests/tools/test_read_file.py +++ b/tests/tools/test_read_file.py @@ -28,19 +28,29 @@ def test_supports_blanket_allow_is_false(self, read_file_tool): assert read_file_tool.supports_blanket_allow is False def test_path_is_under_windows_case_insensitive(self, monkeypatch): - monkeypatch.setattr("iac_code.tools.read_file.sys.platform", "win32") + monkeypatch.setattr("iac_code.tools.path_safety.sys.platform", "win32") from iac_code.tools.read_file import _path_is_under assert _path_is_under("C:\\Users\\Alice\\project\\file.txt", "c:/users/alice/project") def test_path_is_under_windows_ntpath_separator_normalization(self, monkeypatch): + import iac_code.tools.path_safety as path_safety import iac_code.tools.read_file as read_file - monkeypatch.setattr(read_file.sys, "platform", "win32") - monkeypatch.setattr(read_file.os, "path", ntpath) + monkeypatch.setattr(path_safety.sys, "platform", "win32") + monkeypatch.setattr(path_safety.os, "path", ntpath) assert read_file._path_is_under("C:\\Users\\Alice\\project\\file.txt", "c:/users/alice/project") + def test_path_is_under_darwin_case_insensitive_volume(self, monkeypatch): + import iac_code.tools.path_safety as path_safety + import iac_code.tools.read_file as read_file + + monkeypatch.setattr(path_safety.sys, "platform", "darwin") + monkeypatch.setattr(path_safety, "_path_case_sensitive", lambda _root: False, raising=False) + + assert read_file._path_is_under("/Users/Alice/project/file.txt", "/users/alice/project") + @pytest.mark.asyncio async def test_read_normal_file(self, tmp_path, read_file_tool): """Test reading a normal file.""" diff --git a/tests/ui/test_pipeline_interrupt_ui.py b/tests/ui/test_pipeline_interrupt_ui.py index 2b376ff8..dec9ac2a 100644 --- a/tests/ui/test_pipeline_interrupt_ui.py +++ b/tests/ui/test_pipeline_interrupt_ui.py @@ -704,6 +704,33 @@ def test_render_pipeline_event_does_not_mutate_pipeline_step_names(self, mock_re class TestPipelineEventStyles: + def test_render_pipeline_warning_prints_non_terminal_warning(self, mock_repl): + from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + + printed = [] + + class CaptureConsole: + def print(self, *args, **kwargs): + if args: + printed.extend(str(arg) for arg in args) + else: + printed.append("") + + mock_repl.renderer = SimpleNamespace(console=CaptureConsole()) + + mock_repl._render_pipeline_event( + PipelineEvent( + type=PipelineEventType.PIPELINE_WARNING, + step_id="deploying", + timestamp=0, + data={"reason": "cleanup_tracking_unavailable"}, + ) + ) + + rendered = "\n".join(printed) + assert "cleanup_tracking_unavailable" in rendered + assert "yellow" in rendered + def test_render_pipeline_event_uses_slate_sky_label_styles(self, mock_repl): from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.ui.pipeline_styles import PIPELINE_STEP_HEADER_STYLE, PIPELINE_TITLE_STYLE diff --git a/tests/utils/test_state_io.py b/tests/utils/test_state_io.py index 3a5435b4..af5511bf 100644 --- a/tests/utils/test_state_io.py +++ b/tests/utils/test_state_io.py @@ -1,14 +1,17 @@ from __future__ import annotations +import errno +import gc import json import os import sys import types +import weakref from pathlib import Path import pytest -from iac_code.utils.state_io import append_jsonl_locked, atomic_write_json, atomic_write_text +from iac_code.utils.state_io import append_jsonl_locked, atomic_write_json, atomic_write_text, safe_replace def test_atomic_write_text_replaces_file_and_removes_temp(tmp_path: Path) -> None: @@ -57,6 +60,90 @@ def test_append_jsonl_locked_writes_one_complete_line_per_record(tmp_path: Path) assert [json.loads(line) for line in lines] == [{"a": 1}, {"b": 2}] +def test_path_lock_registry_reuses_held_lock_for_same_path(tmp_path: Path) -> None: + from iac_code.utils.path_locks import PathLockRegistry + + registry = PathLockRegistry() + path = tmp_path / "state.jsonl" + + with registry.lock_for(path) as first: + with registry.lock_for(path) as second: + assert second is first + + +def test_path_lock_registry_releases_stale_locks_after_callers_drop_references(tmp_path: Path) -> None: + from iac_code.utils.path_locks import PathLockRegistry + + registry = PathLockRegistry() + path = tmp_path / "state.jsonl" + + with registry.lock_for(path) as first: + ref = weakref.ref(first) + + del first + gc.collect() + registry.prune() + + assert ref() is None + + +def test_safe_replace_cross_device_fallback_copies_then_unlinks_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from iac_code.utils import state_io + + src = tmp_path / "legacy.jsonl" + dst = tmp_path / "session" / "session.jsonl" + dst.parent.mkdir() + src.write_text("legacy", encoding="utf-8") + + real_replace = os.replace + + def raise_exdev_for_legacy_src(_src: str | Path, _dst: str | Path) -> None: + if Path(_src) == src: + raise OSError(errno.EXDEV, "Invalid cross-device link") + real_replace(_src, _dst) + + monkeypatch.setattr(state_io.os, "replace", raise_exdev_for_legacy_src) + + safe_replace(src, dst) + + assert dst.read_text(encoding="utf-8") == "legacy" + assert not src.exists() + + +def test_safe_replace_cross_device_fallback_retries_transient_final_replace_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from iac_code.utils import state_io + + src = tmp_path / "legacy.jsonl" + dst = tmp_path / "session" / "session.jsonl" + dst.parent.mkdir() + src.write_text("legacy", encoding="utf-8") + real_replace = os.replace + final_replace_attempts = 0 + + def fail_exdev_then_transient_final_lock(_src: str | Path, _dst: str | Path) -> None: + nonlocal final_replace_attempts + src_path = Path(_src) + if src_path == src: + raise OSError(errno.EXDEV, "Invalid cross-device link") + if src_path.parent == dst.parent and src_path.name.startswith(f".{dst.name}."): + final_replace_attempts += 1 + if final_replace_attempts == 1: + raise PermissionError("target locked") + real_replace(_src, _dst) + + monkeypatch.setattr(state_io.os, "replace", fail_exdev_then_transient_final_lock) + + safe_replace(src, dst, attempts=2, delay=0) + + assert final_replace_attempts == 2 + assert dst.read_text(encoding="utf-8") == "legacy" + assert not src.exists() + + def test_durable_append_jsonl_fsyncs_parent_directory_for_new_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From a36fae75b32942c73972c03895733d9e04674af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Wed, 24 Jun 2026 15:06:10 +0800 Subject: [PATCH 57/59] fix: complete translations after main rebase --- .../i18n/locales/de/LC_MESSAGES/messages.po | 69 +++++++----------- .../i18n/locales/es/LC_MESSAGES/messages.po | 73 ++++++++----------- .../i18n/locales/fr/LC_MESSAGES/messages.po | 73 ++++++++----------- .../i18n/locales/ja/LC_MESSAGES/messages.po | 65 +++++++---------- .../i18n/locales/pt/LC_MESSAGES/messages.po | 73 ++++++++----------- .../i18n/locales/zh/LC_MESSAGES/messages.po | 67 +++++++---------- tests/a2a/test_executor.py | 6 +- 7 files changed, 177 insertions(+), 249 deletions(-) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 213ff6e5..3b0d4e7f 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -90,9 +90,9 @@ msgid "Task canceled." msgstr "Aufgabe abgebrochen." #: src/iac_code/a2a/executor.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Current model {model} does not support image input." -msgstr "Modell {model} unterstützt keinen Thinking-Effort." +msgstr "Aktuelles Modell {model} unterstützt keine Bildeingabe." #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." @@ -1657,7 +1657,6 @@ msgid "cleanup prompt" msgstr "Bereinigungs-Prompt" #: src/iac_code/commands/prompt.py -#, fuzzy msgid "cleanup prompt · removed" msgstr "Bereinigungs-Prompt · entfernt" @@ -2262,9 +2261,8 @@ msgstr "" "abgeschlossen ist." #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Requirements:" -msgstr "erforderlich" +msgstr "Anforderungen:" #: src/iac_code/pipeline/engine/cleanup.py msgid "" @@ -2376,9 +2374,8 @@ msgstr "" "Fortschritt." #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Cleanup resources:" -msgstr "Bereinigungs-Prompts" +msgstr "Bereinigungsressourcen:" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2601,9 +2598,8 @@ msgid "Step {step_id} completed. Conclusion submitted." msgstr "Schritt {step_id} abgeschlossen. Schlussfolgerung übermittelt." #: src/iac_code/pipeline/engine/pipeline_runner.py -#, fuzzy msgid "Pipeline state persistence failed." -msgstr "A2A-Pipeline-Zustand nicht gefunden" +msgstr "Persistenz des Pipeline-Zustands fehlgeschlagen." #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -4005,7 +4001,7 @@ msgid "Command has no handler: {name}" msgstr "Kein Handler für Befehl: {name}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ Rollback-Bereinigung [{badge}] {label}" @@ -4015,29 +4011,24 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Deleting" -msgstr "Bereitstellung" +msgstr "Löschen" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Skipped" -msgstr "Überspringen" +msgstr "Übersprungen" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pending" -msgstr "OpenAI" +msgstr "Ausstehend" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Checking" -msgstr "PRÜFUNG LÄUFT" +msgstr "Prüfen" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Progress" -msgstr "Verarbeitet" +msgstr "Fortschritt" #: src/iac_code/ui/repl.py #, python-brace-format @@ -4059,12 +4050,10 @@ msgid "{progress}; deletion required" msgstr "{progress}; Löschung erforderlich" #: src/iac_code/ui/repl.py -#, fuzzy msgid "stack" -msgstr "ROS Stack" +msgstr "Stack" #: src/iac_code/ui/repl.py -#, fuzzy msgid "resource" msgstr "Ressource" @@ -4092,32 +4081,27 @@ msgstr "" "abgeschlossen." #: src/iac_code/ui/repl.py -#, fuzzy msgid "failed" -msgstr "Fehlgeschlagen" +msgstr "fehlgeschlagen" #: src/iac_code/ui/repl.py -#, fuzzy msgid "pending" -msgstr "OpenAI" +msgstr "ausstehend" #: src/iac_code/ui/repl.py -#, fuzzy msgid "in progress" -msgstr "PRÜFUNG LÄUFT" +msgstr "in Bearbeitung" #: src/iac_code/ui/repl.py -#, fuzzy msgid "completed" -msgstr "Abgeschlossen" +msgstr "abgeschlossen" #: src/iac_code/ui/repl.py -#, fuzzy msgid "skipped" -msgstr "Überspringen" +msgstr "übersprungen" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "{count} {label}" msgstr "{count} {label}" @@ -4165,13 +4149,12 @@ msgstr "" " Chat wurde nicht dauerhaft markiert." #: src/iac_code/ui/repl.py -#, fuzzy msgid "" "Pipeline completed, but the handoff context could not be injected or " "saved." msgstr "" -"Die Pipeline wurde abgeschlossen. Der normale Chat ist aktiv, aber der " -"Übergabekontext konnte nicht eingefügt oder gespeichert werden." +"Pipeline abgeschlossen, aber der Handoff-Kontext konnte nicht eingefügt " +"oder gespeichert werden." #: src/iac_code/ui/repl.py msgid "Judging your input..." @@ -4351,6 +4334,11 @@ msgstr " ✓ {name}: abgeschlossen\n" msgid " ✘ {name}: Failed" msgstr " ✘ {name}: fehlgeschlagen" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Pipeline warning: {reason}" +msgstr "Pipeline-Warnung: {reason}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -4433,14 +4421,13 @@ msgid "Resumed pipeline at step: {step}" msgstr "Pipeline bei Schritt fortgesetzt: {step}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Could not read pipeline state metadata: {reason}" -msgstr "Pipeline-Zustand konnte nicht fortgesetzt werden: {reason}" +msgstr "Pipeline-Zustandsmetadaten konnten nicht gelesen werden: {reason}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pipeline state metadata is invalid; continuing as normal chat." -msgstr "Pipeline-Zustand verwerfen und als normalen Chat fortfahren" +msgstr "Pipeline-Zustandsmetadaten sind ungültig; fahre als normaler Chat fort." #: src/iac_code/ui/repl.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index dbe71cea..328ccf8c 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -91,9 +91,9 @@ msgid "Task canceled." msgstr "Tarea cancelada." #: src/iac_code/a2a/executor.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Current model {model} does not support image input." -msgstr "El modelo {model} no admite effort." +msgstr "El modelo actual {model} no admite entrada de imagen." #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." @@ -1658,7 +1658,6 @@ msgid "cleanup prompt" msgstr "prompt de limpieza" #: src/iac_code/commands/prompt.py -#, fuzzy msgid "cleanup prompt · removed" msgstr "prompt de limpieza · eliminado" @@ -2267,9 +2266,8 @@ msgstr "" " complete." #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Requirements:" -msgstr "obligatorio" +msgstr "Requisitos:" #: src/iac_code/pipeline/engine/cleanup.py msgid "" @@ -2376,9 +2374,8 @@ msgid "- Briefly update the user during cleanup." msgstr "- Actualice brevemente al usuario durante la limpieza." #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Cleanup resources:" -msgstr "Prompts de limpieza" +msgstr "Recursos de limpieza:" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2595,9 +2592,8 @@ msgid "Step {step_id} completed. Conclusion submitted." msgstr "Paso {step_id} completado. Conclusión enviada." #: src/iac_code/pipeline/engine/pipeline_runner.py -#, fuzzy msgid "Pipeline state persistence failed." -msgstr "No se encontró el estado del pipeline A2A" +msgstr "Error al persistir el estado de la pipeline." #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -4007,7 +4003,7 @@ msgid "Command has no handler: {name}" msgstr "El comando no tiene controlador: {name}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ Limpieza de rollback [{badge}] {label}" @@ -4017,29 +4013,24 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Deleting" -msgstr "Despliegue" +msgstr "Eliminando" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Skipped" -msgstr "Omitir" +msgstr "Omitido" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pending" -msgstr "OpenAI" +msgstr "Pendiente" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Checking" -msgstr "Comprobación en curso" +msgstr "Comprobando" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Progress" -msgstr "Procesado" +msgstr "Progreso" #: src/iac_code/ui/repl.py #, python-brace-format @@ -4063,14 +4054,12 @@ msgid "{progress}; deletion required" msgstr "{progress}; se requiere eliminación" #: src/iac_code/ui/repl.py -#, fuzzy msgid "stack" -msgstr "ROS Stack" +msgstr "stack" #: src/iac_code/ui/repl.py -#, fuzzy msgid "resource" -msgstr "Recurso" +msgstr "recurso" #: src/iac_code/ui/repl.py msgid "" @@ -4093,32 +4082,27 @@ msgstr "" "completados." #: src/iac_code/ui/repl.py -#, fuzzy msgid "failed" -msgstr "Error" +msgstr "fallido" #: src/iac_code/ui/repl.py -#, fuzzy msgid "pending" -msgstr "OpenAI" +msgstr "pendiente" #: src/iac_code/ui/repl.py -#, fuzzy msgid "in progress" -msgstr "Comprobación en curso" +msgstr "en curso" #: src/iac_code/ui/repl.py -#, fuzzy msgid "completed" -msgstr "Completado" +msgstr "completado" #: src/iac_code/ui/repl.py -#, fuzzy msgid "skipped" -msgstr "Omitir" +msgstr "omitido" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "{count} {label}" msgstr "{count} {label}" @@ -4165,13 +4149,12 @@ msgstr "" " se marcó como duradero." #: src/iac_code/ui/repl.py -#, fuzzy msgid "" "Pipeline completed, but the handoff context could not be injected or " "saved." msgstr "" -"El pipeline se completó. El chat normal está activo, pero no se pudo " -"inyectar ni guardar el contexto de traspaso." +"La pipeline se completó, pero no se pudo inyectar o guardar el contexto " +"de handoff." #: src/iac_code/ui/repl.py msgid "Judging your input..." @@ -4346,6 +4329,11 @@ msgstr " ✓ {name}: completado\n" msgid " ✘ {name}: Failed" msgstr " ✘ {name}: error" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Pipeline warning: {reason}" +msgstr "Advertencia de la pipeline: {reason}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -4428,14 +4416,15 @@ msgid "Resumed pipeline at step: {step}" msgstr "Pipeline reanudado en el paso: {step}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Could not read pipeline state metadata: {reason}" -msgstr "No se pudo reanudar el estado del pipeline: {reason}" +msgstr "No se pudieron leer los metadatos de estado de la pipeline: {reason}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pipeline state metadata is invalid; continuing as normal chat." -msgstr "Descartar el estado del pipeline y continuar como chat normal" +msgstr "" +"Los metadatos de estado de la pipeline no son válidos; se continúa como " +"chat normal." #: src/iac_code/ui/repl.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 7b954ac8..339e9b9b 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -91,9 +91,9 @@ msgid "Task canceled." msgstr "Tâche annulée." #: src/iac_code/a2a/executor.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Current model {model} does not support image input." -msgstr "Le modèle {model} ne prend pas en charge l’effort de raisonnement." +msgstr "Le modèle actuel {model} ne prend pas en charge l’entrée d’image." #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." @@ -1661,7 +1661,6 @@ msgid "cleanup prompt" msgstr "prompt de nettoyage" #: src/iac_code/commands/prompt.py -#, fuzzy msgid "cleanup prompt · removed" msgstr "prompt de nettoyage · supprimé" @@ -2268,9 +2267,8 @@ msgstr "" "de la suppression." #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Requirements:" -msgstr "obligatoire" +msgstr "Exigences :" #: src/iac_code/pipeline/engine/cleanup.py msgid "" @@ -2379,9 +2377,8 @@ msgid "- Briefly update the user during cleanup." msgstr "- Tenez brièvement l’utilisateur informé pendant le nettoyage." #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Cleanup resources:" -msgstr "Prompts de nettoyage" +msgstr "Ressources de nettoyage :" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2596,9 +2593,8 @@ msgid "Step {step_id} completed. Conclusion submitted." msgstr "Étape {step_id} terminée. Conclusion soumise." #: src/iac_code/pipeline/engine/pipeline_runner.py -#, fuzzy msgid "Pipeline state persistence failed." -msgstr "État du pipeline A2A introuvable" +msgstr "Échec de la persistance de l’état du pipeline." #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -4008,7 +4004,7 @@ msgid "Command has no handler: {name}" msgstr "Aucun gestionnaire pour la commande : {name}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ Nettoyage du rollback [{badge}] {label}" @@ -4018,29 +4014,24 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Deleting" -msgstr "Déploiement" +msgstr "Suppression" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Skipped" -msgstr "Ignorer" +msgstr "Ignoré" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pending" -msgstr "OpenAI" +msgstr "En attente" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Checking" -msgstr "VÉRIFICATION EN COURS" +msgstr "Vérification" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Progress" -msgstr "Traité" +msgstr "Progression" #: src/iac_code/ui/repl.py #, python-brace-format @@ -4062,14 +4053,12 @@ msgid "{progress}; deletion required" msgstr "{progress} ; suppression requise" #: src/iac_code/ui/repl.py -#, fuzzy msgid "stack" -msgstr "ROS Stack" +msgstr "stack" #: src/iac_code/ui/repl.py -#, fuzzy msgid "resource" -msgstr "Ressource" +msgstr "ressource" #: src/iac_code/ui/repl.py msgid "" @@ -4095,32 +4084,27 @@ msgstr "" "terminés." #: src/iac_code/ui/repl.py -#, fuzzy msgid "failed" -msgstr "Échec" +msgstr "échoué" #: src/iac_code/ui/repl.py -#, fuzzy msgid "pending" -msgstr "OpenAI" +msgstr "en attente" #: src/iac_code/ui/repl.py -#, fuzzy msgid "in progress" -msgstr "VÉRIFICATION EN COURS" +msgstr "en cours" #: src/iac_code/ui/repl.py -#, fuzzy msgid "completed" -msgstr "Terminé" +msgstr "terminé" #: src/iac_code/ui/repl.py -#, fuzzy msgid "skipped" -msgstr "Ignorer" +msgstr "ignoré" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "{count} {label}" msgstr "{count} {label}" @@ -4167,13 +4151,12 @@ msgstr "" "normal n'a pas été marqué comme durable." #: src/iac_code/ui/repl.py -#, fuzzy msgid "" "Pipeline completed, but the handoff context could not be injected or " "saved." msgstr "" -"Le pipeline est terminé. Le chat normal est actif, mais le contexte de " -"transfert n'a pas pu être injecté ou enregistré." +"Le pipeline est terminé, mais le contexte de handoff n’a pas pu être " +"injecté ou enregistré." #: src/iac_code/ui/repl.py msgid "Judging your input..." @@ -4353,6 +4336,11 @@ msgstr " ✓ {name} : terminé\n" msgid " ✘ {name}: Failed" msgstr " ✘ {name} : échec" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Pipeline warning: {reason}" +msgstr "Avertissement du pipeline : {reason}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -4435,14 +4423,15 @@ msgid "Resumed pipeline at step: {step}" msgstr "Pipeline repris à l'étape : {step}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Could not read pipeline state metadata: {reason}" -msgstr "Impossible de reprendre l'état du pipeline : {reason}" +msgstr "Impossible de lire les métadonnées d'état du pipeline : {reason}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pipeline state metadata is invalid; continuing as normal chat." -msgstr "Ignorer l'état du pipeline et continuer comme un chat normal" +msgstr "" +"Les métadonnées d’état du pipeline sont invalides ; poursuite en chat " +"normal." #: src/iac_code/ui/repl.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 0cfaf096..bf22dd73 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -78,9 +78,9 @@ msgid "Task canceled." msgstr "タスクがキャンセルされました。" #: src/iac_code/a2a/executor.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Current model {model} does not support image input." -msgstr "モデル {model} は effort(思考の負荷)をサポートしていません。" +msgstr "現在のモデル {model} は画像入力をサポートしていません。" #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." @@ -1611,7 +1611,6 @@ msgid "cleanup prompt" msgstr "クリーンアッププロンプト" #: src/iac_code/commands/prompt.py -#, fuzzy msgid "cleanup prompt · removed" msgstr "クリーンアッププロンプト · 削除済み" @@ -2188,9 +2187,8 @@ msgid "" msgstr "パイプラインのロールバック後もクラウドリソースのクリーンアップが必要です。今すぐクリーンアップし、削除が完了するまで確認を続けてください。" #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Requirements:" -msgstr "必須" +msgstr "要件:" #: src/iac_code/pipeline/engine/cleanup.py msgid "" @@ -2280,9 +2278,8 @@ msgid "- Briefly update the user during cleanup." msgstr "- クリーンアップ中はユーザーに簡潔に進捗を伝えてください。" #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Cleanup resources:" -msgstr "クリーンアッププロンプト" +msgstr "クリーンアップリソース:" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2476,9 +2473,8 @@ msgid "Step {step_id} completed. Conclusion submitted." msgstr "ステップ {step_id} が完了しました。結論を送信しました。" #: src/iac_code/pipeline/engine/pipeline_runner.py -#, fuzzy msgid "Pipeline state persistence failed." -msgstr "A2A パイプライン状態が見つかりません" +msgstr "パイプライン状態の永続化に失敗しました。" #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -3835,7 +3831,7 @@ msgid "Command has no handler: {name}" msgstr "ハンドラーがないコマンドです:{name}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ ロールバッククリーンアップ [{badge}] {label}" @@ -3845,29 +3841,24 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Deleting" -msgstr "デプロイ" +msgstr "削除中" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Skipped" -msgstr "スキップ" +msgstr "スキップ済み" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pending" -msgstr "OpenAI" +msgstr "保留中" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Checking" -msgstr "チェック進行中" +msgstr "確認中" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Progress" -msgstr "処理しました" +msgstr "進捗" #: src/iac_code/ui/repl.py #, python-brace-format @@ -3889,12 +3880,10 @@ msgid "{progress}; deletion required" msgstr "{progress}; 削除が必要です" #: src/iac_code/ui/repl.py -#, fuzzy msgid "stack" -msgstr "ROS スタック" +msgstr "スタック" #: src/iac_code/ui/repl.py -#, fuzzy msgid "resource" msgstr "リソース" @@ -3915,34 +3904,29 @@ msgid "↺ Rollback cleanup resume: all {count} records are completed." msgstr "↺ ロールバッククリーンアップ再開: {count} 件のレコードはすべて完了しています。" #: src/iac_code/ui/repl.py -#, fuzzy msgid "failed" msgstr "失敗" #: src/iac_code/ui/repl.py -#, fuzzy msgid "pending" -msgstr "OpenAI" +msgstr "保留中" #: src/iac_code/ui/repl.py -#, fuzzy msgid "in progress" -msgstr "チェック進行中" +msgstr "進行中" #: src/iac_code/ui/repl.py -#, fuzzy msgid "completed" msgstr "完了" #: src/iac_code/ui/repl.py -#, fuzzy msgid "skipped" -msgstr "スキップ" +msgstr "スキップ済み" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "{count} {label}" -msgstr "{label} {count} 件" +msgstr "{count} 件の{label}" #: src/iac_code/ui/repl.py #, python-brace-format @@ -3981,11 +3965,10 @@ msgid "" msgstr "パイプライン状態の永続化に失敗しました。通常チャットへのハンドオフは永続化済みとしてマークされていません。" #: src/iac_code/ui/repl.py -#, fuzzy msgid "" "Pipeline completed, but the handoff context could not be injected or " "saved." -msgstr "パイプラインが完了しました。通常チャットは有効ですが、引き継ぎコンテキストを注入または保存できませんでした。" +msgstr "パイプラインは完了しましたが、handoff コンテキストを注入または保存できませんでした。" #: src/iac_code/ui/repl.py msgid "Judging your input..." @@ -4151,6 +4134,11 @@ msgstr " ✓ {name}: 完了\n" msgid " ✘ {name}: Failed" msgstr " ✘ {name}: 失敗" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Pipeline warning: {reason}" +msgstr "パイプライン警告: {reason}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -4231,14 +4219,13 @@ msgid "Resumed pipeline at step: {step}" msgstr "パイプラインをステップ {step} から再開しました" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Could not read pipeline state metadata: {reason}" -msgstr "パイプライン状態を再開できませんでした: {reason}" +msgstr "パイプライン状態メタデータを読み取れませんでした: {reason}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pipeline state metadata is invalid; continuing as normal chat." -msgstr "パイプライン状態を破棄して通常のチャットとして続行" +msgstr "パイプライン状態メタデータが無効です。通常チャットとして続行します。" #: src/iac_code/ui/repl.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 40908db2..55b40178 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -90,9 +90,9 @@ msgid "Task canceled." msgstr "Tarefa cancelada." #: src/iac_code/a2a/executor.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Current model {model} does not support image input." -msgstr "O modelo {model} não suporta nível de esforço (effort)." +msgstr "O modelo atual {model} não oferece suporte a entrada de imagem." #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." @@ -1649,7 +1649,6 @@ msgid "cleanup prompt" msgstr "prompt de limpeza" #: src/iac_code/commands/prompt.py -#, fuzzy msgid "cleanup prompt · removed" msgstr "prompt de limpeza · removido" @@ -2256,9 +2255,8 @@ msgstr "" "concluída." #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Requirements:" -msgstr "obrigatório" +msgstr "Requisitos:" #: src/iac_code/pipeline/engine/cleanup.py msgid "" @@ -2365,9 +2363,8 @@ msgid "- Briefly update the user during cleanup." msgstr "- Atualize brevemente o usuário durante a limpeza." #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Cleanup resources:" -msgstr "Prompts de limpeza" +msgstr "Recursos de limpeza:" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2579,9 +2576,8 @@ msgid "Step {step_id} completed. Conclusion submitted." msgstr "Etapa {step_id} concluída. Conclusão enviada." #: src/iac_code/pipeline/engine/pipeline_runner.py -#, fuzzy msgid "Pipeline state persistence failed." -msgstr "Estado do pipeline A2A não encontrado" +msgstr "Falha ao persistir o estado do pipeline." #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -3974,7 +3970,7 @@ msgid "Command has no handler: {name}" msgstr "Comando sem tratador: {name}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ Limpeza de rollback [{badge}] {label}" @@ -3984,29 +3980,24 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Deleting" -msgstr "Implantação" +msgstr "Excluindo" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Skipped" -msgstr "Ignorar" +msgstr "Ignorado" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pending" -msgstr "OpenAI" +msgstr "Pendente" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Checking" -msgstr "Verificação em andamento" +msgstr "Verificando" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Progress" -msgstr "Processado" +msgstr "Progresso" #: src/iac_code/ui/repl.py #, python-brace-format @@ -4028,14 +4019,12 @@ msgid "{progress}; deletion required" msgstr "{progress}; exclusão necessária" #: src/iac_code/ui/repl.py -#, fuzzy msgid "stack" -msgstr "ROS Stack" +msgstr "stack" #: src/iac_code/ui/repl.py -#, fuzzy msgid "resource" -msgstr "Recurso" +msgstr "recurso" #: src/iac_code/ui/repl.py msgid "" @@ -4059,32 +4048,27 @@ msgstr "" "concluídos." #: src/iac_code/ui/repl.py -#, fuzzy msgid "failed" -msgstr "Falhou" +msgstr "falhou" #: src/iac_code/ui/repl.py -#, fuzzy msgid "pending" -msgstr "OpenAI" +msgstr "pendente" #: src/iac_code/ui/repl.py -#, fuzzy msgid "in progress" -msgstr "Verificação em andamento" +msgstr "em andamento" #: src/iac_code/ui/repl.py -#, fuzzy msgid "completed" -msgstr "Concluído" +msgstr "concluído" #: src/iac_code/ui/repl.py -#, fuzzy msgid "skipped" -msgstr "Ignorar" +msgstr "ignorado" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "{count} {label}" msgstr "{count} {label}" @@ -4131,13 +4115,12 @@ msgstr "" "foi marcado como durável." #: src/iac_code/ui/repl.py -#, fuzzy msgid "" "Pipeline completed, but the handoff context could not be injected or " "saved." msgstr "" -"O pipeline foi concluído. O chat normal está ativo, mas o contexto de " -"transferência não pôde ser injetado ou salvo." +"O pipeline foi concluído, mas o contexto de handoff não pôde ser injetado" +" ou salvo." #: src/iac_code/ui/repl.py msgid "Judging your input..." @@ -4315,6 +4298,11 @@ msgstr " ✓ {name}: concluído\n" msgid " ✘ {name}: Failed" msgstr " ✘ {name}: falhou" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Pipeline warning: {reason}" +msgstr "Aviso do pipeline: {reason}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -4397,14 +4385,15 @@ msgid "Resumed pipeline at step: {step}" msgstr "Pipeline retomado na etapa: {step}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Could not read pipeline state metadata: {reason}" -msgstr "Não foi possível retomar o estado do pipeline: {reason}" +msgstr "Não foi possível ler os metadados de estado do pipeline: {reason}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pipeline state metadata is invalid; continuing as normal chat." -msgstr "Descartar estado do pipeline e continuar como chat normal" +msgstr "" +"Os metadados de estado do pipeline são inválidos; continuando como chat " +"normal." #: src/iac_code/ui/repl.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 4e089d3d..a9b40763 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -78,9 +78,9 @@ msgid "Task canceled." msgstr "任务已取消。" #: src/iac_code/a2a/executor.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Current model {model} does not support image input." -msgstr "模型 {model} 不支持调整思考强度。" +msgstr "当前模型 {model} 不支持图片输入。" #: src/iac_code/a2a/pipeline_executor.py msgid "A temporary error occurred. Please retry." @@ -1597,9 +1597,8 @@ msgid "cleanup prompt" msgstr "清理提示词" #: src/iac_code/commands/prompt.py -#, fuzzy msgid "cleanup prompt · removed" -msgstr "清理提示词 · 已移除" +msgstr "清理提示 · 已移除" #: src/iac_code/commands/prompt.py msgid "Instruction Memory" @@ -2172,9 +2171,8 @@ msgid "" msgstr "管道回滚后仍有云资源需要清理。请立即清理,并持续检查直到删除完成。" #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Requirements:" -msgstr "必填" +msgstr "需求:" #: src/iac_code/pipeline/engine/cleanup.py msgid "" @@ -2258,9 +2256,8 @@ msgid "- Briefly update the user during cleanup." msgstr "- 清理过程中简要向用户更新进展。" #: src/iac_code/pipeline/engine/cleanup.py -#, fuzzy msgid "Cleanup resources:" -msgstr "清理提示词" +msgstr "清理资源:" #: src/iac_code/pipeline/engine/cleanup.py #, python-brace-format @@ -2446,9 +2443,8 @@ msgid "Step {step_id} completed. Conclusion submitted." msgstr "步骤 {step_id} 已完成。结论已提交。" #: src/iac_code/pipeline/engine/pipeline_runner.py -#, fuzzy msgid "Pipeline state persistence failed." -msgstr "未找到 A2A pipeline 状态" +msgstr "管道状态持久化失败。" #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -3786,7 +3782,7 @@ msgid "Command has no handler: {name}" msgstr "命令没有处理器:{name}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "↺ Rollback cleanup [{badge}] {label}" msgstr "↺ 回滚清理 [{badge}] {label}" @@ -3796,29 +3792,24 @@ msgid "{kind} {resource_id}" msgstr "{kind} {resource_id}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Deleting" -msgstr "部署执行" +msgstr "删除中" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Skipped" -msgstr "跳过" +msgstr "已跳过" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pending" -msgstr "OpenAI" +msgstr "待处理" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Checking" msgstr "检查中" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Progress" -msgstr "已处理" +msgstr "进度" #: src/iac_code/ui/repl.py #, python-brace-format @@ -3840,12 +3831,10 @@ msgid "{progress}; deletion required" msgstr "{progress};需要删除" #: src/iac_code/ui/repl.py -#, fuzzy msgid "stack" -msgstr "ROS 资源栈" +msgstr "资源栈" #: src/iac_code/ui/repl.py -#, fuzzy msgid "resource" msgstr "资源" @@ -3866,34 +3855,29 @@ msgid "↺ Rollback cleanup resume: all {count} records are completed." msgstr "↺ 回滚清理恢复:所有 {count} 条记录都已完成。" #: src/iac_code/ui/repl.py -#, fuzzy msgid "failed" -msgstr "失败" +msgstr "已失败" #: src/iac_code/ui/repl.py -#, fuzzy msgid "pending" -msgstr "OpenAI" +msgstr "待处理" #: src/iac_code/ui/repl.py -#, fuzzy msgid "in progress" -msgstr "检查中" +msgstr "进行中" #: src/iac_code/ui/repl.py -#, fuzzy msgid "completed" msgstr "已完成" #: src/iac_code/ui/repl.py -#, fuzzy msgid "skipped" -msgstr "跳过" +msgstr "已跳过" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "{count} {label}" -msgstr "{count} 条{label}" +msgstr "{count} 个{label}" #: src/iac_code/ui/repl.py #, python-brace-format @@ -3932,11 +3916,10 @@ msgid "" msgstr "管道状态持久化失败。普通聊天 handoff 未标记为已持久化。" #: src/iac_code/ui/repl.py -#, fuzzy msgid "" "Pipeline completed, but the handoff context could not be injected or " "saved." -msgstr "Pipeline 已完成。普通聊天已启用,但交接上下文无法注入或保存。" +msgstr "管道已完成,但无法注入或保存 handoff 上下文。" #: src/iac_code/ui/repl.py msgid "Judging your input..." @@ -4100,6 +4083,11 @@ msgstr " ✓ {name}: 已完成\n" msgid " ✘ {name}: Failed" msgstr " ✘ {name}: 失败" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Pipeline warning: {reason}" +msgstr "管道警告:{reason}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -4180,14 +4168,13 @@ msgid "Resumed pipeline at step: {step}" msgstr "已恢复 pipeline 到步骤:{step}" #: src/iac_code/ui/repl.py -#, fuzzy, python-brace-format +#, python-brace-format msgid "Could not read pipeline state metadata: {reason}" -msgstr "无法恢复 pipeline 状态:{reason}" +msgstr "无法读取管道状态元数据:{reason}" #: src/iac_code/ui/repl.py -#, fuzzy msgid "Pipeline state metadata is invalid; continuing as normal chat." -msgstr "丢弃 pipeline 状态并按普通聊天继续" +msgstr "管道状态元数据无效;继续按普通聊天处理。" #: src/iac_code/ui/repl.py #, python-brace-format diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index 8423e361..4ca31db0 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -556,14 +556,14 @@ def fake_is_model_multimodal(model, *, provider_key=None, base_url=None, api_key ) return False - monkeypatch.setattr("iac_code.a2a.executor.get_active_provider_key", lambda: "openapi_compatible") + monkeypatch.setattr("iac_code.a2a.executor.get_active_provider_key", lambda: "openai_compatible") monkeypatch.setattr( "iac_code.a2a.executor.get_provider_config", lambda provider_key: {"keyName": provider_key, "apiBase": "https://example.test/v1"}, ) monkeypatch.setattr( "iac_code.a2a.executor.load_credentials", - lambda model=None: {"openapi_compatible": "test-key"}, + lambda model=None: {"openai_compatible": "test-key"}, ) monkeypatch.setattr("iac_code.a2a.executor.is_model_multimodal", fake_is_model_multimodal) @@ -579,7 +579,7 @@ def fake_is_model_multimodal(model, *, provider_key=None, base_url=None, api_key assert seen == { "model": "custom-vl", - "provider_key": "openapi_compatible", + "provider_key": "openai_compatible", "base_url": "https://example.test/v1", "api_key": "test-key", } From 1b0d49d6b3e00a4be7fa64b2bf6268f9aa4bbb5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Wed, 24 Jun 2026 15:40:21 +0800 Subject: [PATCH 58/59] Fix CI failures after selling console updates --- scripts/a2a/selling_console.py | 43 ++++++++++++++----- tests/a2a/test_selling_console_frontend.py | 27 +++++++++++- tests/repl_e2e/test_run_pipeline_scenarios.py | 17 +++++++- tests/ui/test_renderer_helpers.py | 2 +- 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/scripts/a2a/selling_console.py b/scripts/a2a/selling_console.py index f0ee2d44..80805a26 100644 --- a/scripts/a2a/selling_console.py +++ b/scripts/a2a/selling_console.py @@ -26,10 +26,6 @@ a2a_debugger = importlib.import_module("scripts.a2a.debugger") WEB_ROOT = Path(__file__).resolve().with_name("selling_console_web") -STATIC_CONTENT_TYPES = { - "/styles.css": "text/css; charset=utf-8", - "/app.js": "application/javascript; charset=utf-8", -} TEMPLATE_PLACEHOLDERS = ( "__DEFAULTS_JSON__", "__DEFAULT_SERVER_URL_ATTR__", @@ -38,6 +34,17 @@ ) +@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 @@ -71,9 +78,9 @@ def _json_for_template(value: object) -> str: def _static_asset_version() -> str: digest = hashlib.sha256() - for asset_name in ("styles.css", "app.js"): - digest.update(asset_name.encode("utf-8")) - digest.update((WEB_ROOT / asset_name).read_bytes()) + for asset in STATIC_ASSETS: + digest.update(asset.path.name.encode("utf-8")) + digest.update(asset.path.read_bytes()) return digest.hexdigest()[:12] @@ -103,13 +110,27 @@ def _send_text(handler: BaseHTTPRequestHandler, status: int, body: str, content_ 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: - if path not in STATIC_CONTENT_TYPES: + 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 - candidate = (WEB_ROOT / path.lstrip("/")).resolve() - if not candidate.is_file() or WEB_ROOT.resolve() not in candidate.parents: + if not candidate.is_file(): return False - _send_text(handler, 200, candidate.read_text(encoding="utf-8"), STATIC_CONTENT_TYPES[path]) + _send_text(handler, 200, candidate.read_text(encoding="utf-8"), asset.content_type) return True diff --git a/tests/a2a/test_selling_console_frontend.py b/tests/a2a/test_selling_console_frontend.py index 107f4482..aa8ba335 100644 --- a/tests/a2a/test_selling_console_frontend.py +++ b/tests/a2a/test_selling_console_frontend.py @@ -4,6 +4,7 @@ import os import shutil import subprocess +import tempfile from pathlib import Path import pytest @@ -36,11 +37,35 @@ def node_command() -> list[str]: def run_node_script(source: str) -> dict: - result = subprocess.run([*node_command(), "-e", source], capture_output=True, text=True, check=False) + 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, 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): + command_seen.extend(str(part) for part in command) + assert capture_output is True + assert text is True + assert check is False + 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: diff --git a/tests/repl_e2e/test_run_pipeline_scenarios.py b/tests/repl_e2e/test_run_pipeline_scenarios.py index 6a07d36d..c0e27ff8 100644 --- a/tests/repl_e2e/test_run_pipeline_scenarios.py +++ b/tests/repl_e2e/test_run_pipeline_scenarios.py @@ -16,6 +16,19 @@ def _load_runner(): return module +def _repl_pty_unit_instance(runner, *, args, run_dir: Path, cwd: Path, env: dict[str, str]): + pty = runner.ReplPty.__new__(runner.ReplPty) + pty.args = args + pty.run_dir = run_dir + pty.cwd = cwd + pty.env = env + pty.events = [] + pty.raw_chunks = [] + pty.child = None + pty._live_transcript = False + return pty + + def _install_flow_fake_pty( monkeypatch, runner, @@ -556,7 +569,7 @@ def send(self, text): args = runner.parse_args(["--allow-real-cloud"]) child = FakeChild() - pty = runner.ReplPty(args=args, run_dir=tmp_path, cwd=tmp_path, env={}) + pty = _repl_pty_unit_instance(runner, args=args, run_dir=tmp_path, cwd=tmp_path, env={}) pty.child = child matched = pty.expect_any((r"Pipeline completed",), description="pipeline completed", timeout=10) @@ -595,7 +608,7 @@ def read_nonblocking(self, size, timeout): raise runner.pexpect.TIMEOUT("done") monkeypatch.setattr(runner.time, "sleep", lambda _seconds: None) - pty = runner.ReplPty(args=args, run_dir=tmp_path, cwd=tmp_path, env={}) + pty = _repl_pty_unit_instance(runner, args=args, run_dir=tmp_path, cwd=tmp_path, env={}) pty.child = FakeChild() pty.sendline("x" * (runner.PTY_SEND_CHUNK_SIZE + 1)) diff --git a/tests/ui/test_renderer_helpers.py b/tests/ui/test_renderer_helpers.py index ec4de4a5..f172cca8 100644 --- a/tests/ui/test_renderer_helpers.py +++ b/tests/ui/test_renderer_helpers.py @@ -256,7 +256,7 @@ def test_replay_history_renders_structured_image_blocks_as_image_refs(self): output = console.file.getvalue() assert "see " in output assert "[Image #8]" in output - assert "file:///tmp/session-image-8.png" in output + assert renderer._file_url("/tmp/session-image-8.png") in output def test_any_segment_has_verbose_content(self): renderer = make_renderer() From 5b4c53c565eb1147a449618881b3f15caa6fca14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Wed, 24 Jun 2026 15:50:44 +0800 Subject: [PATCH 59/59] Decode selling console frontend test output as UTF-8 --- tests/a2a/test_selling_console_frontend.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/a2a/test_selling_console_frontend.py b/tests/a2a/test_selling_console_frontend.py index aa8ba335..b175b305 100644 --- a/tests/a2a/test_selling_console_frontend.py +++ b/tests/a2a/test_selling_console_frontend.py @@ -40,7 +40,13 @@ 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, check=False) + 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) @@ -49,11 +55,12 @@ def test_run_node_script_uses_file_instead_of_inline_eval(monkeypatch: pytest.Mo source = 'console.log(JSON.stringify({"ok": true}));' command_seen: list[str] = [] - def fake_run(command, *, capture_output, text, check): + 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