Health
Stream
@@ -1400,6 +1493,8 @@ def render_index_html(config: DebuggerConfig) -> str:
};
const byId = (id) => document.getElementById(id);
+ const supportedImageMediaTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
+ const maxImageBytes = 5 * 1024 * 1024;
function createExecutionTree() {
return {
@@ -1495,6 +1590,78 @@ def render_index_html(config: DebuggerConfig) -> str:
};
}
+ function selectedImageFiles() {
+ const input = byId("image-input");
+ if (!input || !input.files) {
+ return [];
+ }
+ return Array.from(input.files);
+ }
+
+ function formatBytes(value) {
+ const bytes = Number(value) || 0;
+ if (bytes < 1024) {
+ return `${bytes} B`;
+ }
+ if (bytes < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KiB`;
+ }
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
+ }
+
+ function updateImageSummary() {
+ const summary = byId("image-summary");
+ if (!summary) {
+ return;
+ }
+ const files = selectedImageFiles();
+ if (!files.length) {
+ summary.textContent = "No images selected.";
+ return;
+ }
+ summary.textContent = files
+ .map((file) => `${file.name || "image"} (${formatBytes(file.size)})`)
+ .join(", ");
+ }
+
+ function readFileAsDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener("load", () => resolve(String(reader.result || "")));
+ reader.addEventListener("error", () => reject(reader.error || new Error("Failed to read image file.")));
+ reader.readAsDataURL(file);
+ });
+ }
+
+ function imageBase64FromDataUrl(dataUrl) {
+ const commaIndex = dataUrl.indexOf(",");
+ if (commaIndex < 0) {
+ throw new Error("Image file did not produce a valid data URL.");
+ }
+ return dataUrl.slice(commaIndex + 1);
+ }
+
+ async function readSelectedImages() {
+ const files = selectedImageFiles();
+ const images = [];
+ for (const file of files) {
+ const mediaType = String(file.type || "").toLowerCase();
+ if (!supportedImageMediaTypes.has(mediaType)) {
+ throw new Error(`${file.name || "Selected image"} uses unsupported image type ${mediaType || "unknown"}.`);
+ }
+ if (file.size > maxImageBytes) {
+ throw new Error(`${file.name || "Selected image"} is larger than 5 MiB.`);
+ }
+ const dataUrl = await readFileAsDataUrl(file);
+ images.push({
+ filename: file.name || "image",
+ mediaType,
+ bytes: imageBase64FromDataUrl(dataUrl)
+ });
+ }
+ return images;
+ }
+
function appendRawEvent(kind, value) {
let row = null;
if (kind === "snapshot") {
@@ -1647,18 +1814,30 @@ def render_index_html(config: DebuggerConfig) -> str:
const status = statusUpdate.status && typeof statusUpdate.status === "object" ? statusUpdate.status : {};
return {
kind: "status_update",
- taskId: statusUpdate.taskId || statusUpdate.task_id || "",
- contextId: statusUpdate.contextId || statusUpdate.context_id || "",
+ taskId: (
+ statusUpdate.deliveryTaskId ||
+ statusUpdate.delivery_task_id ||
+ statusUpdate.taskId ||
+ statusUpdate.task_id ||
+ ""
+ ),
+ contextId: (
+ statusUpdate.deliveryContextId ||
+ statusUpdate.delivery_context_id ||
+ statusUpdate.contextId ||
+ statusUpdate.context_id ||
+ ""
+ ),
state: status.state || ""
};
}
const task = payload.task && typeof payload.task === "object" ? payload.task : payload;
- if (task.id || task.taskId || task.task_id) {
+ if (task.id || task.taskId || task.task_id || task.deliveryTaskId || task.delivery_task_id) {
const status = task.status && typeof task.status === "object" ? task.status : {};
return {
kind: "task_submitted",
- taskId: task.id || task.taskId || task.task_id || "",
- contextId: task.contextId || task.context_id || "",
+ taskId: task.deliveryTaskId || task.delivery_task_id || task.id || task.taskId || task.task_id || "",
+ contextId: task.deliveryContextId || task.delivery_context_id || task.contextId || task.context_id || "",
state: status.state || ""
};
}
@@ -1672,11 +1851,31 @@ def render_index_html(config: DebuggerConfig) -> str:
}
function recordTaskIdentity(identity, role = "active") {
- const taskId = String(identity && (identity.taskId || identity.task_id || identity.id) || "");
+ const taskId = String(
+ identity &&
+ (
+ identity.deliveryTaskId ||
+ identity.delivery_task_id ||
+ identity.taskId ||
+ identity.task_id ||
+ identity.id
+ ) ||
+ ""
+ );
if (!taskId) {
return null;
}
- const contextId = String(identity && (identity.contextId || identity.context_id || state.contextId) || "");
+ const contextId = String(
+ identity &&
+ (
+ identity.deliveryContextId ||
+ identity.delivery_context_id ||
+ identity.contextId ||
+ identity.context_id ||
+ state.contextId
+ ) ||
+ ""
+ );
const taskState = String(identity && (identity.state || identity.status || "") || "");
const existing = state.taskHistory.find((item) => item.taskId === taskId);
const next = {
@@ -1726,6 +1925,14 @@ def render_index_html(config: DebuggerConfig) -> str:
return stateValue === "TASK_STATE_SUBMITTED" || stateValue === "TASK_STATE_WORKING";
}
+ function isTerminalPipelineTaskState(value) {
+ const stateValue = String(value || "")
+ .toLowerCase()
+ .replace(/^task_state_/, "")
+ .replace(/-/g, "_");
+ return ["canceled", "cancelled", "failed", "denied", "completed"].includes(stateValue);
+ }
+
function shouldKeepActiveTaskId(identity) {
return identity && isWorkingA2ATaskState(identity.state);
}
@@ -1769,14 +1976,12 @@ def render_index_html(config: DebuggerConfig) -> str:
if (activeTaskInput && state.activeTaskId && activeTaskInput.value.trim() !== state.activeTaskId) {
activeTaskInput.value = state.activeTaskId;
}
- if (activeTaskInput && !state.activeTaskId && state.normalHandoffReady && activeTaskInput.value.trim()) {
- activeTaskInput.value = "";
- }
if (
activeTaskInput &&
!state.activeTaskId &&
- state.normalHandoffReady &&
- activeTaskInput.value.trim() === state.taskId
+ activeTaskInput.value.trim() &&
+ (state.normalHandoffReady ||
+ (isTerminalPipelineTaskState(state.status) && activeTaskInput.value.trim() === state.taskId))
) {
activeTaskInput.value = "";
}
@@ -1815,10 +2020,15 @@ def render_index_html(config: DebuggerConfig) -> str:
}
function streamTaskIdForControls(controls) {
- if (state.normalHandoffReady && !controls.activeTaskId) {
+ const activeTaskId = controls.activeTaskId || state.activeTaskId;
+ const pipelineTaskId = controls.taskId || state.taskId;
+ if (activeTaskId && !(isTerminalPipelineTaskState(state.status) && activeTaskId === pipelineTaskId)) {
+ return activeTaskId;
+ }
+ if (state.normalHandoffReady || isTerminalPipelineTaskState(state.status)) {
return "";
}
- return controls.activeTaskId || state.activeTaskId || controls.taskId || state.taskId;
+ return pipelineTaskId;
}
function cancelTaskIdForControls(controls) {
@@ -2598,6 +2808,13 @@ def render_index_html(config: DebuggerConfig) -> str:
if (type === "input_required") {
return {label: "input required", text: summarizeValue(data), className: "timeline-permission"};
}
+ if (type === "pipeline_canceled") {
+ return {
+ label: "pipeline canceled",
+ text: data.reason || summarizeValue(data),
+ className: "timeline-canceled"
+ };
+ }
if (type.endsWith("_completed") && Object.prototype.hasOwnProperty.call(data, "conclusion")) {
return {
label: type.replace(/_/g, " "),
@@ -2848,8 +3065,22 @@ def render_index_html(config: DebuggerConfig) -> str:
}
state.status = String(envelope.status || envelope.state || envelope.pipelineStatus || state.status || "running");
- state.taskId = String(envelope.taskId || envelope.task_id || state.taskId || "");
- state.contextId = String(envelope.contextId || envelope.context_id || state.contextId || "");
+ state.taskId = String(
+ envelope.deliveryTaskId ||
+ envelope.delivery_task_id ||
+ envelope.taskId ||
+ envelope.task_id ||
+ state.taskId ||
+ ""
+ );
+ state.contextId = String(
+ envelope.deliveryContextId ||
+ envelope.delivery_context_id ||
+ envelope.contextId ||
+ envelope.context_id ||
+ state.contextId ||
+ ""
+ );
if (state.taskId) {
if (!state.normalHandoffReady && !state.activeTaskId) {
state.activeTaskId = state.taskId;
@@ -3340,7 +3571,8 @@ def render_index_html(config: DebuggerConfig) -> str:
}
function snapshotNormalHandoff(snapshot) {
- return snapshotObject(snapshot && (snapshot.normalHandoff || snapshot.normal_handoff));
+ const envelope = snapshotEnvelope(snapshot);
+ return snapshotObject(envelope && (envelope.normalHandoff || envelope.normal_handoff));
}
function normalHandoffSummary(snapshot) {
@@ -4021,6 +4253,7 @@ def render_index_html(config: DebuggerConfig) -> str:
updateRawRequest(requestRow, {status: "ok", response: body});
appendRawEvent("sse", {type: "cancel", body});
applyPipelineEvent(body);
+ await fetchStateIfAvailable();
} catch (error) {
updateRawRequest(requestRow, {
status: "error",
@@ -4034,6 +4267,7 @@ def render_index_html(config: DebuggerConfig) -> str:
async function streamMessage() {
const controls = readControls();
+ const images = await readSelectedImages();
const payload = {
serverUrl: controls.serverUrl,
cwd: controls.cwd,
@@ -4041,6 +4275,9 @@ def render_index_html(config: DebuggerConfig) -> str:
taskId: streamTaskIdForControls(controls),
prompt: controls.prompt
};
+ if (images.length) {
+ payload.images = images;
+ }
const requestRow = appendRawEvent("request", {method: "POST", path: "/api/message/stream", payload});
state.streamsInFlight += 1;
state.status = "streaming";
@@ -4100,6 +4337,9 @@ def render_index_html(config: DebuggerConfig) -> str:
} catch {
parsed = raw;
}
+ if (parsed && typeof parsed === "object" && (parsed.type === "error" || parsed.error)) {
+ state.status = "error";
+ }
const rawRow = appendRawEvent("sse", parsed);
if (rawRow) {
applyPipelineEvent(parsed, rawRow, {alreadyRecorded: true});
@@ -4308,6 +4548,10 @@ def render_index_html(config: DebuggerConfig) -> str:
element.readOnly = true;
}
});
+ const imageInput = byId("image-input");
+ if (imageInput) {
+ imageInput.disabled = true;
+ }
["health-button", "stream-button", "fetch-state-button", "cancel-button"].forEach((id) => {
const button = byId(id);
if (button) {
@@ -4351,6 +4595,10 @@ def render_index_html(config: DebuggerConfig) -> str:
element.setAttribute("readonly", "readonly");
}
});
+ const imageInput = clone.querySelector("#image-input");
+ if (imageInput) {
+ imageInput.setAttribute("disabled", "disabled");
+ }
["health-button", "stream-button", "fetch-state-button", "cancel-button"].forEach((id) => {
const button = clone.querySelector(`#${cssEscape(id)}`);
if (button) {
@@ -4435,11 +4683,13 @@ def render_index_html(config: DebuggerConfig) -> str:
byId("cancel-button").addEventListener("click", (event) => withButtonState(event.currentTarget, cancelTask));
byId("stream-button").addEventListener("click", (event) => withStreamAction(event.currentTarget, streamMessage));
byId("export-html-button").addEventListener("click", exportCurrentHtmlSnapshot);
+ byId("image-input").addEventListener("change", updateImageSummary);
if (isExportMode) {
restoreExportState(exportPayload);
configureExportMode();
}
+ updateImageSummary();
renderPipeline();
renderRaw();
@@ -4595,8 +4845,8 @@ def _message_stream_body(body: dict[str, Any]) -> tuple[str, dict[str, Any]]:
task_id = str(body.get("taskId", ""))
if not cwd:
raise ValueError("cwd is required")
- if not prompt:
- raise ValueError("prompt is required")
+ if not prompt and not body.get("images"):
+ raise ValueError("prompt or image is required")
payload = build_message_stream_payload(
cwd=cwd,
prompt=prompt,
@@ -4604,6 +4854,7 @@ def _message_stream_body(body: dict[str, Any]) -> tuple[str, dict[str, Any]]:
task_id=task_id,
request_id=str(uuid.uuid4()),
message_id=str(uuid.uuid4()),
+ images=body.get("images"),
)
return server_url, payload
@@ -4634,6 +4885,58 @@ def _send_sse_error(handler: BaseHTTPRequestHandler, status: int, message: str)
raise
+def _send_sse_event(handler: BaseHTTPRequestHandler, status: int, event: dict[str, Any]) -> None:
+ body = f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode("utf-8")
+ try:
+ handler.send_response(status)
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
+ handler.send_header("Content-Length", str(len(body)))
+ handler.end_headers()
+ handler.wfile.write(body)
+ except OSError as exc:
+ if _is_client_disconnect_error(exc):
+ return
+ raise
+
+
+def _jsonrpc_error_message(value: Any) -> str | None:
+ if not isinstance(value, dict):
+ return None
+ error = value.get("error")
+ if isinstance(error, dict):
+ message = error.get("message")
+ recoverable_task_id = _recoverable_task_id_from_jsonrpc_error(error)
+ if isinstance(message, str) and message:
+ if recoverable_task_id and not _message_has_resume_guidance(message, recoverable_task_id):
+ return f"{message} Resume task {recoverable_task_id}."
+ return message
+ return json.dumps(error, ensure_ascii=False)
+ if isinstance(error, str) and error:
+ return error
+ return None
+
+
+def _message_has_resume_guidance(message: str, task_id: str) -> bool:
+ return f"resume task {task_id}".casefold() in message.casefold()
+
+
+def _recoverable_task_id_from_jsonrpc_error(error: dict[str, Any]) -> str | None:
+ data = error.get("data")
+ if isinstance(data, dict):
+ task_id = data.get("recoverableTaskId")
+ return task_id if isinstance(task_id, str) and task_id else None
+ if isinstance(data, list):
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ metadata = item.get("metadata")
+ if isinstance(metadata, dict):
+ task_id = metadata.get("recoverableTaskId")
+ if isinstance(task_id, str) and task_id:
+ return task_id
+ return None
+
+
def create_server(config: DebuggerConfig) -> ThreadingHTTPServer:
class A2APipelineDebuggerHandler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None:
@@ -4679,6 +4982,32 @@ def do_POST(self) -> None:
server_url, payload = _message_stream_body(body)
try:
with _open_sse_stream(server_url, payload) as response:
+ content_type = str(response.headers.get("Content-Type", "")).lower()
+ if "text/event-stream" not in content_type:
+ raw = response.read()
+ data, _text = _decode_json_text(raw)
+ message = _jsonrpc_error_message(data)
+ if message:
+ event = {
+ "type": "error",
+ "error": message,
+ "statusCode": response.status,
+ "body": data,
+ }
+ append_debug_log(config, "sse", event)
+ _send_sse_event(self, 200, event)
+ return
+ append_debug_log(
+ config,
+ "error",
+ {
+ "ok": False,
+ "error": "Target server returned a non-SSE response",
+ "statusCode": response.status,
+ },
+ )
+ _send_sse_error(self, 502, "Target server returned a non-SSE response")
+ return
self.send_response(response.status)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.end_headers()
@@ -4738,7 +5067,7 @@ def main(argv: list[str] | None = None) -> None:
replay_export=load_debug_log_export(args.load_log_dir) if args.load_log_dir else None,
)
server = create_server(config)
- host, port = server.server_address
+ host, port = server.server_address[:2]
print(f"A2A pipeline debugger listening on http://{host}:{port}", flush=True)
print(f"A2A pipeline debugger logs: {config.log_dir}", flush=True)
try:
diff --git a/scripts/a2a/e2e/README.md b/scripts/a2a/e2e/README.md
index 1e158d52..0898ba5d 100644
--- a/scripts/a2a/e2e/README.md
+++ b/scripts/a2a/e2e/README.md
@@ -60,6 +60,11 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \
--scenario scenario1 \
--scenario selection-waiting \
--scenario ask-waiting \
+ --scenario image-initial \
+ --scenario image-ask-waiting \
+ --scenario image-selection-waiting \
+ --scenario image-normal-handoff \
+ --scenario image-interrupt \
--scenario step1-running \
--scenario step2-running \
--scenario step3-running \
@@ -75,12 +80,17 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \
--scenario rollback-step2 \
--scenario rollback-step3 \
--scenario rollback-step4 \
- --scenario rollback-step5
+ --scenario rollback-step5 \
+ --scenario rollback-step5-cleanup \
+ --scenario rollback-step5-cleanup-recovery
```
Provider, tool, and cloud execution scenarios are guarded by default. Use
`--allow-real-cloud` only when you intentionally want to run against real
providers and Alibaba Cloud credentials.
+The rollback step5 cleanup scenarios intentionally leave the second stack in
+ROS as proof that cleanup only removed the rollback leftover; delete that stack
+after you finish inspecting the run.
## What Each Scenario Covers
@@ -88,11 +98,16 @@ providers and Alibaba Cloud credentials.
not a separate runner or a special mode; it lives in the same scenario matrix as
the rest of the tests.
-| Scenario | Where the server is killed | Recovery input | Main assertion |
+| Scenario | Cut point / special condition | Recovery input | Main assertion |
| --- | --- | --- | --- |
| `scenario1` | After pipeline completion and one normal-chat follow-up | Ask what the previous normal-chat question was | Normal-chat history survives restart; VSwitch evidence exists. |
| `selection-waiting` | Step 4 waits for candidate selection | `你随便选一个方案。` without `taskId` | Waiting step4 task is recovered and selected; VSwitch evidence exists. |
| `ask-waiting` | `ask_user_question` waits for user input | Clarification answers without `taskId` | Pending ask input is recovered and pipeline completes; VSwitch evidence exists. |
+| `image-initial` | Initial user message is the static `initial.png` image fixture | Candidate selection text | The image starts the pipeline, reaches step4 selection, completes, and produces VSwitch evidence. |
+| `image-ask-waiting` | `ask_user_question` waits for user input, then the server restarts | Static `ask-first-answer.png` / `ask-second-answer.png` image fixtures without `taskId` | Pending ask input is recovered, image answers hydrate the recovered task, and the pipeline completes with VSwitch evidence. |
+| `image-selection-waiting` | Step 4 waits for candidate selection, then the server restarts | Static `selection.png` image fixture without `taskId` | Waiting step4 task is recovered, the image selection is accepted, and VSwitch evidence exists. |
+| `image-normal-handoff` | Pipeline completes and hands off to normal chat; the normal follow-up is static `normal-followup.png`, then the server restarts | Normal-chat recovery question without `taskId` | Image follow-up stays in the same `contextId`, uses a new normal-chat task, and completed handoff state survives restart. |
+| `image-interrupt` | Step 3 receives static `rollback-interrupt.png` as an image rollback to `intent_parsing`, then the server restarts | `继续`, plus selection when needed | The image interrupt is recognized, the pipeline completes as a security-group task, and final deployment evidence is not VSwitch. |
| `step1-running` | `intent_parsing` running | `继续` | Running pipeline task is recovered and completes; VSwitch evidence exists. |
| `step2-running` | `architecture_planning` running | `继续` | Running pipeline task is recovered and completes; VSwitch evidence exists. |
| `step3-running` | `evaluate_candidates` candidate/sub-pipeline running | `继续` | Sub-pipeline state is recovered and completes; VSwitch evidence exists. |
@@ -101,6 +116,8 @@ the rest of the tests.
| `normal-running` | Normal-chat response streaming after pipeline handoff | `继续`, then history check | Normal-chat task recovery keeps same `contextId` history. |
| `cancel-step1` ... `cancel-step5` | Active pipeline task is canceled at the named step | Normal-chat follow-up after cancel, then restart and history check | Canceled snapshot stays canceled; normal-chat history survives restart. |
| `rollback-step1` ... `rollback-step5` | Step 3 receives rollback to `intent_parsing`, then the named post-rollback step is killed | `继续`, plus selection when needed | Post-rollback pipeline completes as a security-group task, not VSwitch. |
+| `rollback-step5-cleanup` | First step5 stack is observed, then rollback creates a second stack and hands off to normal chat | A normal-chat follow-up triggers cleanup | First rollback stack reaches cleanup complete and is deleted in ROS; second stack remains. |
+| `rollback-step5-cleanup-recovery` | Same as `rollback-step5-cleanup`, then the server is killed after cleanup starts | `继续` in normal chat after restart | Cleanup is triggered again after restart; first stack is deleted and second stack remains. |
| `fault-after-snapshot` | Deterministic crash after A2A pipeline snapshot persistence | `继续`, plus selection when needed | `GetTask` / `ListTasks` expose the recovered task and the pipeline completes. |
## Representative Inputs
@@ -136,6 +153,12 @@ Rollback scenarios interrupt step 3 with:
回退到 intent_parsing,选择一个已有vpc,创建一个安全组
```
+Image scenarios send a small text prompt plus static PNG fixtures from
+`scripts/a2a/e2e/fixtures/text-images/`. The fixture manifest pins the text,
+file name, media type, byte size, and SHA-256 hash. A scenario run also writes
+`image-fixtures/manifest.json`; fixed prompts should show `source: static`.
+Only ad-hoc or CLI-overridden text falls back to runtime image rendering.
+
## Recommended Order
When stabilizing changes, run the smaller or more diagnostic cases first:
@@ -144,10 +167,13 @@ When stabilizing changes, run the smaller or more diagnostic cases first:
2. `scenario1`
3. `selection-waiting`
4. `ask-waiting`
-5. `step1-running` through `step5-running`
-6. `normal-running`
-7. `cancel-step1` through `cancel-step5`
-8. `rollback-step1` through `rollback-step5`
+5. `image-initial`, `image-ask-waiting`, and `image-selection-waiting`
+6. `image-normal-handoff` and `image-interrupt`
+7. `step1-running` through `step5-running`
+8. `normal-running`
+9. `cancel-step1` through `cancel-step5`
+10. `rollback-step1` through `rollback-step5`
+11. `rollback-step5-cleanup`, then `rollback-step5-cleanup-recovery`
## Preflight
@@ -224,6 +250,7 @@ Important files:
- `*.task-get.json` and `*.task-list.json`: redacted `GetTask` / `ListTasks` artifacts when captured by the scenario.
- `server-1.*.log` and `server-2.*.log`: server logs before and after restart.
- `a2a-server.yml`: generated server config.
+- `image-fixtures/manifest.json`: image input fixture usage for image scenarios, including whether each image came from a static repository fixture or runtime rendering.
- `workspace/`: default A2A metadata cwd and generated tool outputs unless `--cwd` is provided.
- `preflight.json`: provider preflight result unless `--skip-preflight` is used.
diff --git a/scripts/a2a/e2e/README.zh-CN.md b/scripts/a2a/e2e/README.zh-CN.md
index 76aa4727..0fa7c431 100644
--- a/scripts/a2a/e2e/README.zh-CN.md
+++ b/scripts/a2a/e2e/README.zh-CN.md
@@ -56,6 +56,11 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \
--scenario scenario1 \
--scenario selection-waiting \
--scenario ask-waiting \
+ --scenario image-initial \
+ --scenario image-ask-waiting \
+ --scenario image-selection-waiting \
+ --scenario image-normal-handoff \
+ --scenario image-interrupt \
--scenario step1-running \
--scenario step2-running \
--scenario step3-running \
@@ -71,22 +76,31 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \
--scenario rollback-step2 \
--scenario rollback-step3 \
--scenario rollback-step4 \
- --scenario rollback-step5
+ --scenario rollback-step5 \
+ --scenario rollback-step5-cleanup \
+ --scenario rollback-step5-cleanup-recovery
```
provider、tool、真实云调用场景默认会被保护住。只有确认要使用真实 provider 和阿里云凭证
时,才加 `--allow-real-cloud`。
+`rollback-step5-cleanup` 这两个场景会故意保留第 2 个 stack,作为“只清理回滚残留”的验收
+证据;检查完 run 产物后请再手工或通过后续流程删除它。
## 每个场景覆盖什么
`scenario1` 是历史遗留名称,表示“pipeline 完成后恢复 normal chat”的基线场景。它不是
单独 runner,也不是特殊模式,而是完整场景矩阵中的一个场景。
-| 场景 | kill server 的位置 | 恢复时输入 | 主要验收 |
+| 场景 | 切点 / 特殊条件 | 恢复时输入 | 主要验收 |
| --- | --- | --- | --- |
| `scenario1` | pipeline 完成并完成一轮 normal-chat follow-up 后 | 询问上一条 normal-chat 问题是什么 | normal-chat 历史重启后仍可用;存在 VSwitch 证据。 |
| `selection-waiting` | step4 等待候选方案选择时 | 不带 `taskId` 发送 `你随便选一个方案。` | 能恢复等待中的 step4 task 并完成选择;存在 VSwitch 证据。 |
| `ask-waiting` | `ask_user_question` 等待用户输入时 | 不带 `taskId` 发送澄清回答 | 能恢复 pending ask 输入并完成 pipeline;存在 VSwitch 证据。 |
+| `image-initial` | 首轮用户消息就是静态 `initial.png` 图片 fixture | 文本选择候选方案 | 图片能启动 pipeline,进入 step4 选择,最终完成并产生 VSwitch 证据。 |
+| `image-ask-waiting` | `ask_user_question` 等待用户输入,随后重启 server | 不带 `taskId` 发送静态 `ask-first-answer.png` / `ask-second-answer.png` 图片 fixture | pending ask 输入能恢复,图片回答能 hydrate 到恢复后的 task,最终完成并产生 VSwitch 证据。 |
+| `image-selection-waiting` | step4 等待候选方案选择,随后重启 server | 不带 `taskId` 发送静态 `selection.png` 图片 fixture | 能恢复等待中的 step4 task,图片选择被接受,并产生 VSwitch 证据。 |
+| `image-normal-handoff` | pipeline 完成并 handoff 到 normal chat;normal follow-up 是静态 `normal-followup.png`,随后重启 server | 不带 `taskId` 发送 normal-chat 恢复问题 | 图片 follow-up 保持同一个 `contextId`,使用新的 normal-chat task;completed handoff 状态重启后仍可恢复。 |
+| `image-interrupt` | step3 收到静态 `rollback-interrupt.png` 图片,表示回滚到 `intent_parsing`,随后重启 server | `继续`,必要时再选择方案 | 图片 interrupt 能被识别;pipeline 以安全组任务完成,最终部署证据不是 VSwitch。 |
| `step1-running` | `intent_parsing` 运行中 | `继续` | running pipeline task 能恢复并完成;存在 VSwitch 证据。 |
| `step2-running` | `architecture_planning` 运行中 | `继续` | running pipeline task 能恢复并完成;存在 VSwitch 证据。 |
| `step3-running` | `evaluate_candidates` 的 candidate/sub-pipeline 运行中 | `继续` | sub-pipeline 状态能恢复并完成;存在 VSwitch 证据。 |
@@ -95,6 +109,8 @@ provider、tool、真实云调用场景默认会被保护住。只有确认要
| `normal-running` | pipeline handoff 后的 normal-chat 响应流式输出中 | `继续`,随后检查历史 | normal-chat task 恢复后仍保持同一个 `contextId` 历史。 |
| `cancel-step1` ... `cancel-step5` | 在指定 step cancel 活跃 pipeline task | cancel 后 normal-chat follow-up,重启后检查历史 | canceled snapshot 保持 canceled;normal-chat 历史重启后仍可用。 |
| `rollback-step1` ... `rollback-step5` | step3 收到回滚到 `intent_parsing`,随后在回滚后的指定 step kill | `继续`,必要时再选择方案 | 回滚后的 pipeline 以安全组任务完成,不再是 VSwitch。 |
+| `rollback-step5-cleanup` | 第一次 step5 stack 已被观测后触发回滚,随后第二次 step5 创建新 stack 并进入 normal chat | normal-chat follow-up 触发 cleanup | 第 1 个回滚残留 stack 在 cleanup snapshot 中完成,且 ROS 中已删除;第 2 个 stack 仍保留。 |
+| `rollback-step5-cleanup-recovery` | 基于 `rollback-step5-cleanup`,在第 1 个 stack cleanup 开始后 kill server | 重启后在 normal chat 发送 `继续` | 恢复后重新触发 cleanup;第 1 个 stack 被删除,第 2 个 stack 仍保留。 |
| `fault-after-snapshot` | A2A pipeline snapshot 持久化后确定性 crash | `继续`,必要时再选择方案 | `GetTask` / `ListTasks` 能看到恢复 task,pipeline 能完成。 |
## 代表输入
@@ -129,6 +145,12 @@ rollback 场景会在 step3 发送:
回退到 intent_parsing,选择一个已有vpc,创建一个安全组
```
+图片场景会发送一个很短的读图提示词,并附带
+`scripts/a2a/e2e/fixtures/text-images/` 下的静态 PNG fixture。fixture manifest 会固化
+文本、文件名、媒体类型、字节数和 SHA-256。每次场景运行还会写
+`image-fixtures/manifest.json`;固定 prompt 应显示 `source: static`。只有临时输入或通过
+CLI 覆盖后的文本,才会回退到运行时渲染图片。
+
## 推荐执行顺序
稳定或回归时,建议从更小、更容易定位问题的场景开始:
@@ -137,10 +159,13 @@ rollback 场景会在 step3 发送:
2. `scenario1`
3. `selection-waiting`
4. `ask-waiting`
-5. `step1-running` 到 `step5-running`
-6. `normal-running`
-7. `cancel-step1` 到 `cancel-step5`
-8. `rollback-step1` 到 `rollback-step5`
+5. `image-initial`、`image-ask-waiting` 和 `image-selection-waiting`
+6. `image-normal-handoff` 和 `image-interrupt`
+7. `step1-running` 到 `step5-running`
+8. `normal-running`
+9. `cancel-step1` 到 `cancel-step5`
+10. `rollback-step1` 到 `rollback-step5`
+11. `rollback-step5-cleanup`,再跑 `rollback-step5-cleanup-recovery`
## Preflight
@@ -215,6 +240,7 @@ uv run python scripts/a2a/e2e/run_recovery_scenarios.py \
- `*.task-get.json` 和 `*.task-list.json`:场景捕获到的、经过脱敏的 `GetTask` / `ListTasks` artifact。
- `server-1.*.log` 和 `server-2.*.log`:重启前后的 server 日志。
- `a2a-server.yml`:生成的 server 配置。
+- `image-fixtures/manifest.json`:图片场景的图片输入 fixture 使用情况,包括每张图来自仓库静态 fixture 还是运行时渲染。
- `workspace/`:默认 A2A metadata cwd;除非指定 `--cwd`,工具输出和生成模板会写到这里。
- `preflight.json`:provider preflight 结果;使用 `--skip-preflight` 时不会生成。
diff --git a/scripts/a2a/e2e/common.py b/scripts/a2a/e2e/common.py
index f881daf9..5438e06b 100644
--- a/scripts/a2a/e2e/common.py
+++ b/scripts/a2a/e2e/common.py
@@ -172,6 +172,7 @@ def stream_message(
timeout: float,
context_id: str = "",
task_id: str = "",
+ images: list[dict[str, Any]] | None = None,
redaction_env: dict[str, str] | None = None,
) -> StreamSummary:
payload = build_message_stream_payload(
@@ -181,6 +182,7 @@ def stream_message(
task_id=task_id,
request_id=str(uuid.uuid4()),
message_id=str(uuid.uuid4()),
+ images=images,
)
_append_jsonl(run_dir / "requests.jsonl", {"name": name, "payload": payload, "at": _utc_now()}, redaction_env)
request = Request(
@@ -253,14 +255,16 @@ def run_llm_preflight(
}
except subprocess.TimeoutExpired as exc:
elapsed = time.monotonic() - started
- output = _redact_sensitive_text("\n".join(part for part in [exc.stdout, exc.stderr] if part), preflight_env)
+ stdout = _subprocess_output_text(exc.stdout)
+ stderr = _subprocess_output_text(exc.stderr)
+ output = _redact_sensitive_text("\n".join(part for part in [stdout, stderr] if part), preflight_env)
payload = {
"ok": False,
"returnCode": None,
"elapsedSeconds": round(elapsed, 3),
"summary": f"timed out after {timeout:.0f}s" + (f": {_compact_text(output)}" if output else ""),
- "stdout": _redact_sensitive_text(exc.stdout or "", preflight_env),
- "stderr": _redact_sensitive_text(exc.stderr or "", preflight_env),
+ "stdout": _redact_sensitive_text(stdout, preflight_env),
+ "stderr": _redact_sensitive_text(stderr, preflight_env),
}
_write_json(run_dir / "preflight.json", payload)
return payload
@@ -452,6 +456,14 @@ def _split_python_command(value: str) -> list[str]:
return parts
+def _subprocess_output_text(value: str | bytes | None) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ return value
+
+
def _redact_sensitive_text(text: str, env: dict[str, str] | None) -> str:
redacted = text
for name, value in (env or {}).items():
diff --git a/scripts/a2a/e2e/fixtures/text-images/ask-first-answer.png b/scripts/a2a/e2e/fixtures/text-images/ask-first-answer.png
new file mode 100644
index 00000000..92c5a9e1
Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/ask-first-answer.png differ
diff --git a/scripts/a2a/e2e/fixtures/text-images/ask-second-answer.png b/scripts/a2a/e2e/fixtures/text-images/ask-second-answer.png
new file mode 100644
index 00000000..c776f80e
Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/ask-second-answer.png differ
diff --git a/scripts/a2a/e2e/fixtures/text-images/initial.png b/scripts/a2a/e2e/fixtures/text-images/initial.png
new file mode 100644
index 00000000..a4fae552
Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/initial.png differ
diff --git a/scripts/a2a/e2e/fixtures/text-images/manifest.json b/scripts/a2a/e2e/fixtures/text-images/manifest.json
new file mode 100644
index 00000000..d8586e39
--- /dev/null
+++ b/scripts/a2a/e2e/fixtures/text-images/manifest.json
@@ -0,0 +1,44 @@
+{
+ "initial": {
+ "filename": "initial.png",
+ "text": "选择一个已有vpc,创建一个vswitch",
+ "mediaType": "image/png",
+ "byteSize": 12697,
+ "sha256": "2f773773c5b528cb7fdafde969d464b19d4d3022c1e6f2ad85b162f98f7ff82e"
+ },
+ "selection": {
+ "filename": "selection.png",
+ "text": "你随便选一个方案。",
+ "mediaType": "image/png",
+ "byteSize": 9907,
+ "sha256": "3aa92a48eed5c37115f18dc89a058ebbb06ac5eeea59f2870d4d58b355ac924b"
+ },
+ "normal-followup": {
+ "filename": "normal-followup.png",
+ "text": "你刚才创建了什么",
+ "mediaType": "image/png",
+ "byteSize": 8684,
+ "sha256": "03a9b1006f840c0bb8ef4f2bd75819033489f4e4f14d92abd7e12b591dd9c26d"
+ },
+ "ask-first-answer": {
+ "filename": "ask-first-answer.png",
+ "text": "我要创建云网络资源;本次只选择已有 VPC 创建一个 VSwitch,不部署 ECS、EIP、SLB 或 Nginx。",
+ "mediaType": "image/png",
+ "byteSize": 35073,
+ "sha256": "499fd5648baafe7d9904259a1eb3408b65556a39a778b2a2a89b89058bcd61b0"
+ },
+ "ask-second-answer": {
+ "filename": "ask-second-answer.png",
+ "text": "选择一个已有 VPC,创建一个 VSwitch;地域、可用区和网段你按低成本默认值推荐。",
+ "mediaType": "image/png",
+ "byteSize": 32302,
+ "sha256": "0fdb1c4d4fce2038f9e5a4107ba8a7e96658a3ca2c705c834c4c93ca23b93dc5"
+ },
+ "rollback-interrupt": {
+ "filename": "rollback-interrupt.png",
+ "text": "回退到 intent_parsing,选择一个已有vpc,创建一个安全组",
+ "mediaType": "image/png",
+ "byteSize": 20967,
+ "sha256": "1dfa25bba58757704b27a7ee8f44a42f4e69045730bba5320986194c873a5937"
+ }
+}
diff --git a/scripts/a2a/e2e/fixtures/text-images/normal-followup.png b/scripts/a2a/e2e/fixtures/text-images/normal-followup.png
new file mode 100644
index 00000000..2f9864a7
Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/normal-followup.png differ
diff --git a/scripts/a2a/e2e/fixtures/text-images/rollback-interrupt.png b/scripts/a2a/e2e/fixtures/text-images/rollback-interrupt.png
new file mode 100644
index 00000000..50512a84
Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/rollback-interrupt.png differ
diff --git a/scripts/a2a/e2e/fixtures/text-images/selection.png b/scripts/a2a/e2e/fixtures/text-images/selection.png
new file mode 100644
index 00000000..cf8f0407
Binary files /dev/null and b/scripts/a2a/e2e/fixtures/text-images/selection.png differ
diff --git a/scripts/a2a/e2e/run_recovery_scenarios.py b/scripts/a2a/e2e/run_recovery_scenarios.py
index 52bea6b6..82c782d3 100644
--- a/scripts/a2a/e2e/run_recovery_scenarios.py
+++ b/scripts/a2a/e2e/run_recovery_scenarios.py
@@ -11,6 +11,9 @@
from __future__ import annotations
import argparse
+import base64
+import hashlib
+import io
import json
import os
import signal
@@ -26,6 +29,9 @@
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
+import yaml
+from PIL import Image, ImageDraw, ImageFont
+
E2E_SCRIPTS_DIR = Path(__file__).resolve().parent
A2A_SCRIPTS_DIR = E2E_SCRIPTS_DIR.parent
for scripts_dir in (E2E_SCRIPTS_DIR, A2A_SCRIPTS_DIR):
@@ -76,10 +82,35 @@
INTERVENING_ASK_ANSWER = "使用默认配置(可用区和网段自动规划),继续。"
ROLLBACK_PROMPT = "回退到 intent_parsing,选择一个已有vpc,创建一个安全组"
CONTINUE_PROMPT = "继续"
+CLEANUP_RECOVERY_PROMPT = (
+ "请只回复“OK,继续”。不要调用任何工具,不要查询任何云资源,不要删除任何资源。"
+ "如果系统有后台 cleanup 恢复流程,请让它自行完成。"
+)
+CLEANUP_PROMPT_METADATA_TYPE = "pipeline_cleanup_prompt"
+CLEANUP_EVENT_TYPES = frozenset(
+ {
+ "cleanup_started",
+ "cleanup_progress",
+ "cleanup_completed",
+ "cleanup_failed",
+ }
+)
+CLEANUP_ACTIVE_STATUSES = frozenset({"pending", "started", "in_progress", "failed"})
+IMAGE_TEXT_PROMPT = "请读取图片中的文字,并将图片中的文字作为本轮用户输入执行。"
+STATIC_TEXT_IMAGE_FIXTURE_ROOT = E2E_SCRIPTS_DIR / "fixtures" / "text-images"
+STATIC_TEXT_IMAGE_FIXTURES = {
+ "initial": DEFAULT_INITIAL_PROMPT,
+ "selection": DEFAULT_SELECTION_PROMPT,
+ "normal-followup": DEFAULT_NORMAL_FOLLOWUP_PROMPT,
+ "ask-first-answer": ASK_FIRST_ANSWER,
+ "ask-second-answer": ASK_SECOND_ANSWER,
+ "rollback-interrupt": ROLLBACK_PROMPT,
+}
VSWITCH_MARKERS = ("ALIYUN::ECS::VSwitch", "VSwitchId", "vsw-", "VSwitch", "交换机")
SECURITY_GROUP_MARKERS = ("ALIYUN::ECS::SecurityGroup", "SecurityGroupId", "sg-", "安全组")
TERMINAL_STATES = {"TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_INPUT_REQUIRED"}
+ROS_STACK_DELETED_STATUSES = {"DELETE_COMPLETE"}
@dataclass
@@ -102,6 +133,127 @@ class EventMatch:
summary: StreamSummary
+class TextImageFixtureStore:
+ def __init__(self, root: Path, static_root: Path = STATIC_TEXT_IMAGE_FIXTURE_ROOT) -> None:
+ self.root = root
+ self.root.mkdir(parents=True, exist_ok=True)
+ self.manifest_path = self.root / "manifest.json"
+ self.static_root = static_root
+
+ def part(self, key: str, text: str) -> dict[str, Any]:
+ safe_key = _safe_fixture_key(key)
+ path = self._static_fixture_path(safe_key, text)
+ source = "static"
+ if path is None:
+ path = self.root / f"{safe_key}.png"
+ source = "generated"
+ if not path.exists():
+ path.write_bytes(_render_text_png(text))
+ raw = path.read_bytes()
+ self._record_manifest(safe_key, text=text, path=path, byte_size=len(raw), source=source)
+ return {
+ "filename": path.name,
+ "mediaType": "image/png",
+ "bytes": base64.b64encode(raw).decode("ascii"),
+ }
+
+ def _static_fixture_path(self, key: str, text: str) -> Path | None:
+ try:
+ manifest = json.loads((self.static_root / "manifest.json").read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ if not isinstance(manifest, dict):
+ return None
+ entry = manifest.get(key)
+ if not isinstance(entry, dict) or entry.get("text") != text or entry.get("mediaType") != "image/png":
+ return None
+ filename = entry.get("filename")
+ if not isinstance(filename, str) or not filename:
+ return None
+ path = self.static_root / filename
+ return path if path.is_file() else None
+
+ def _record_manifest(self, key: str, *, text: str, path: Path, byte_size: int, source: str) -> None:
+ try:
+ manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ manifest = {}
+ if not isinstance(manifest, dict):
+ manifest = {}
+ manifest[key] = {
+ "text": text,
+ "path": str(path),
+ "mediaType": "image/png",
+ "byteSize": byte_size,
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
+ "source": source,
+ }
+ _write_json(self.manifest_path, manifest)
+
+
+def _safe_fixture_key(value: str) -> str:
+ safe = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in value.strip().lower())
+ return safe.strip("-") or "input"
+
+
+def _render_text_png(text: str) -> bytes:
+ font = _load_text_image_font(size=34)
+ lines = _wrap_text_for_image(text)
+ padding = 40
+ line_spacing = 12
+ probe = Image.new("RGB", (1, 1), "white")
+ draw = ImageDraw.Draw(probe)
+ boxes = [draw.textbbox((0, 0), line, font=font) for line in lines]
+ text_width = int(max((right - left for left, _top, right, _bottom in boxes), default=360))
+ line_heights = [int(bottom - top) for _left, top, _right, bottom in boxes] or [40]
+ width = int(max(760, min(1600, text_width + padding * 2)))
+ height = int(max(220, sum(line_heights) + line_spacing * max(0, len(lines) - 1) + padding * 2))
+ image = Image.new("RGB", (width, height), "white")
+ draw = ImageDraw.Draw(image)
+ y = padding
+ for line, line_height in zip(lines, line_heights, strict=False):
+ draw.text((padding, y), line, fill=(16, 24, 39), font=font)
+ y += line_height + line_spacing
+ output = io.BytesIO()
+ image.save(output, format="PNG")
+ return output.getvalue()
+
+
+def _wrap_text_for_image(text: str, *, max_chars: int = 26) -> list[str]:
+ lines: list[str] = []
+ for raw_line in text.splitlines() or [text]:
+ line = raw_line.strip()
+ if not line:
+ lines.append("")
+ continue
+ while len(line) > max_chars:
+ lines.append(line[:max_chars])
+ line = line[max_chars:]
+ if line:
+ lines.append(line)
+ return lines or [""]
+
+
+def _load_text_image_font(*, size: int) -> Any:
+ candidates = [
+ "/System/Library/Fonts/PingFang.ttc",
+ "/System/Library/Fonts/Hiragino Sans GB.ttc",
+ "/System/Library/Fonts/STHeiti Light.ttc",
+ "/Library/Fonts/Arial Unicode.ttf",
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+ ]
+ for candidate in candidates:
+ if Path(candidate).is_file():
+ try:
+ return ImageFont.truetype(candidate, size=size)
+ except OSError:
+ continue
+ return ImageFont.load_default()
+
+
class BackgroundStream:
def __init__(
self,
@@ -114,6 +266,7 @@ def __init__(
timeout: float,
context_id: str = "",
task_id: str = "",
+ images: list[dict[str, Any]] | None = None,
redaction_env: dict[str, str] | None = None,
) -> None:
self.server_url = server_url
@@ -124,6 +277,7 @@ def __init__(
self.timeout = timeout
self.context_id = context_id
self.task_id = task_id
+ self.images = images
self.redaction_env = redaction_env
self.summary = StreamSummary(name=name, prompt=prompt, request_task_id=task_id)
self.events: list[Any] = []
@@ -182,6 +336,7 @@ def _run(self) -> None:
task_id=self.task_id,
request_id=str(uuid.uuid4()),
message_id=str(uuid.uuid4()),
+ images=self.images,
)
_append_jsonl(
self.run_dir / "requests.jsonl",
@@ -230,6 +385,7 @@ def __init__(self, args: argparse.Namespace, *, scenario: str) -> None:
self.server_cwd = str(Path(args.server_cwd).expanduser().resolve())
self.run_dir = _scenario_run_dir(args, scenario)
self.run_dir.mkdir(parents=True, exist_ok=True)
+ self.image_fixtures = TextImageFixtureStore(self.run_dir / "image-fixtures")
self.workspace_dir = Path(args.cwd).expanduser().resolve() if args.cwd else self.run_dir / "workspace"
self.workspace_dir.mkdir(parents=True, exist_ok=True)
self.cwd = str(self.workspace_dir)
@@ -314,6 +470,7 @@ def stream(
name: str,
context_id: str | None = None,
task_id: str | None = None,
+ images: list[dict[str, Any]] | None = None,
) -> StreamSummary:
summary = stream_message(
server_url=self.server_url,
@@ -324,12 +481,31 @@ def stream(
name=name,
run_dir=self.run_dir,
timeout=self.args.stream_timeout,
+ images=images,
redaction_env=self.server_env,
)
self._remember_identity(summary)
self.summaries[name] = summary
return summary
+ def stream_image_text(
+ self,
+ *,
+ text: str,
+ image_key: str,
+ name: str,
+ context_id: str | None = None,
+ task_id: str | None = None,
+ prompt: str = IMAGE_TEXT_PROMPT,
+ ) -> StreamSummary:
+ return self.stream(
+ prompt=prompt,
+ name=name,
+ context_id=context_id,
+ task_id=task_id,
+ images=[self.image_fixtures.part(image_key, text)],
+ )
+
def start_stream(
self,
*,
@@ -337,6 +513,7 @@ def start_stream(
name: str,
context_id: str | None = None,
task_id: str | None = None,
+ images: list[dict[str, Any]] | None = None,
) -> BackgroundStream:
stream = BackgroundStream(
server_url=self.server_url,
@@ -347,6 +524,7 @@ def start_stream(
name=name,
run_dir=self.run_dir,
timeout=self.args.stream_timeout,
+ images=images,
redaction_env=self.server_env,
)
stream.start()
@@ -359,6 +537,24 @@ def start_stream(
self.summaries[name] = stream.summary
return stream
+ def start_stream_image_text(
+ self,
+ *,
+ text: str,
+ image_key: str,
+ name: str,
+ context_id: str | None = None,
+ task_id: str | None = None,
+ prompt: str = IMAGE_TEXT_PROMPT,
+ ) -> BackgroundStream:
+ return self.start_stream(
+ prompt=prompt,
+ name=name,
+ context_id=context_id,
+ task_id=task_id,
+ images=[self.image_fixtures.part(image_key, text)],
+ )
+
def fetch_state(self, name: str) -> Any:
snapshot = fetch_pipeline_state(
server_url=self.server_url,
@@ -573,6 +769,9 @@ def callback(h: ScenarioHarness) -> None:
context_id=h.context_id,
task_id=h.pipeline_task_id,
)
+ h.checks["after-pipeline state has no cleanup activity"] = not _snapshot_has_cleanup_activity(
+ h.snapshots["after_pipeline"]
+ )
normal = h.stream(prompt=args.normal_followup_prompt, name="03-normal-followup", task_id="")
h.checks["normal follow-up stayed in same context"] = normal.context_id == h.context_id
h.checks["normal follow-up used a new task"] = bool(normal.task_id) and normal.task_id != h.pipeline_task_id
@@ -587,6 +786,9 @@ def callback(h: ScenarioHarness) -> None:
context_id=h.context_id,
task_id=h.pipeline_task_id,
)
+ h.checks["after-restart state has no cleanup activity"] = not _snapshot_has_cleanup_activity(
+ h.snapshots["after_restart"]
+ )
recovery = h.stream(prompt=args.recovery_prompt, name="04-recovery-question", task_id="")
h.checks["recovery stayed in same context"] = recovery.context_id == h.context_id
h.checks["recovery used a new task"] = bool(recovery.task_id) and recovery.task_id not in {
@@ -596,6 +798,9 @@ def callback(h: ScenarioHarness) -> None:
h.checks["recovery finished turn"] = _normal_turn_finished(recovery)
h.checks["recovery answer mentions previous question"] = args.expected_text in recovery.text
h.checks["VSwitch evidence found"] = _has_any_marker(_all_evidence(h), VSWITCH_MARKERS)
+ h.checks["scenario1 emitted no cleanup events"] = not _run_dir_has_cleanup_events(h.run_dir)
+ h.checks["scenario1 persisted no cleanup prompt"] = not _session_has_cleanup_prompt(h)
+ h.checks["scenario1 ledger has no cleanup-required resources"] = not _cleanup_ledger_has_required_resources(h)
return _run_with_harness(args, scenario, callback)
@@ -704,6 +909,164 @@ def callback(h: ScenarioHarness) -> None:
return _run_with_harness(args, scenario, callback)
+def run_image_initial(args: argparse.Namespace, scenario: str) -> int:
+ def callback(h: ScenarioHarness) -> None:
+ initial = h.stream_image_text(
+ text=args.initial_prompt,
+ image_key="initial",
+ name="01-initial-image",
+ context_id="",
+ task_id="",
+ )
+ initial = _answer_intervening_ask_inputs(h, initial, name_prefix="01-initial-image")
+ h.checks["image initial reached step4 input_required"] = (
+ initial.last_input_required_step_id == "confirm_and_select"
+ )
+ selection = h.stream(prompt=args.selection_prompt, name="02-select-candidate")
+ h.checks["image initial selection completed pipeline"] = _pipeline_completed(selection)
+ h.checks["image initial VSwitch evidence found"] = _has_any_marker(_all_evidence(h), VSWITCH_MARKERS)
+
+ return _run_with_harness(args, scenario, callback)
+
+
+def run_image_ask_waiting(args: argparse.Namespace, scenario: str) -> int:
+ def callback(h: ScenarioHarness) -> None:
+ initial = h.stream(prompt=ASK_TRIGGER_PROMPT, name="01-ask-trigger", context_id="", task_id="")
+ h.checks["initial reached input_required"] = _reached_input_required(initial)
+ h.checks["input_required is ask_user_question"] = (
+ _latest_pending_kind(h.run_dir / "01-ask-trigger.events.jsonl") == "ask_user_question"
+ )
+ h.kill9_and_restart()
+ snapshot = h.fetch_state("after-restart")
+ h.checks["snapshot still waiting input"] = _snapshot_value(snapshot, "status") == "waiting_input"
+ h.checks["pending input is ask_user_question"] = _pending_kind(snapshot) == "ask_user_question"
+ answer = h.stream_image_text(
+ text=ASK_FIRST_ANSWER,
+ image_key="ask-first-answer",
+ name="02-answer-first-ask-image",
+ task_id="",
+ )
+ _add_hydrated_task_checks(h, answer, "first ask image answer")
+ final_summary = answer
+ if answer.last_input_required_step_id:
+ second = h.stream_image_text(
+ text=ASK_SECOND_ANSWER,
+ image_key="ask-second-answer",
+ name="03-answer-second-ask-image",
+ )
+ _add_same_task_checks(h, second, "second ask image answer")
+ _finish_pipeline_after_possible_input(h, second, args)
+ final_summary = second
+ else:
+ _finish_pipeline_after_possible_input(h, answer, args)
+ h.checks["pipeline completed after ask image recovery"] = _completed_snapshot_or_stream(h, final_summary)
+ h.checks["VSwitch evidence found"] = _has_any_marker(_all_evidence(h), VSWITCH_MARKERS)
+
+ return _run_with_harness(args, scenario, callback)
+
+
+def run_image_selection_waiting(args: argparse.Namespace, scenario: str) -> int:
+ def callback(h: ScenarioHarness) -> None:
+ initial = h.stream(prompt=args.initial_prompt, name="01-initial", context_id="", task_id="")
+ initial = _answer_intervening_ask_inputs(h, initial, name_prefix="01-initial")
+ h.checks["initial reached step4 input_required"] = initial.last_input_required_step_id == "confirm_and_select"
+ h.kill9_and_restart()
+ snapshot = h.fetch_state("after-restart")
+ h.checks["snapshot still waiting input"] = _snapshot_value(snapshot, "status") == "waiting_input"
+ h.checks["pending input is confirm_and_select"] = _pending_step_id(snapshot) == "confirm_and_select"
+ selection = h.stream_image_text(
+ text=args.selection_prompt,
+ image_key="selection",
+ name="02-select-after-restart-image",
+ task_id="",
+ )
+ _add_hydrated_task_checks(h, selection, "selection image answer")
+ h.checks["selection image completed pipeline"] = _pipeline_completed(selection)
+ h.checks["VSwitch evidence found"] = _has_any_marker(_all_evidence(h), VSWITCH_MARKERS)
+
+ return _run_with_harness(args, scenario, callback)
+
+
+def run_image_normal_handoff(args: argparse.Namespace, scenario: str) -> int:
+ def callback(h: ScenarioHarness) -> None:
+ _complete_pipeline(h, args)
+ normal = h.stream_image_text(
+ text=args.normal_followup_prompt,
+ image_key="normal-followup",
+ name="03-normal-followup-image",
+ task_id="",
+ )
+ h.checks["normal image follow-up stayed in same context"] = normal.context_id == h.context_id
+ h.checks["normal image follow-up used a new task"] = (
+ bool(normal.task_id) and normal.task_id != h.pipeline_task_id
+ )
+ h.checks["normal image follow-up finished turn"] = _normal_turn_finished(normal)
+ h.checks["normal image follow-up produced text"] = bool(normal.text.strip())
+ h.kill9_and_restart()
+ h.snapshots["after_restart"] = h.fetch_state("after-restart")
+ _add_completed_snapshot_checks(
+ h.checks,
+ "after-restart state",
+ h.snapshots["after_restart"],
+ context_id=h.context_id,
+ task_id=h.pipeline_task_id,
+ )
+ recovery = h.stream(prompt=args.recovery_prompt, name="04-recovery-question", task_id="")
+ h.checks["normal image recovery stayed in same context"] = recovery.context_id == h.context_id
+ h.checks["normal image recovery finished turn"] = _normal_turn_finished(recovery)
+
+ return _run_with_harness(args, scenario, callback)
+
+
+def run_image_interrupt(args: argparse.Namespace, scenario: str) -> int:
+ def callback(h: ScenarioHarness) -> None:
+ initial = h.start_stream(prompt=args.initial_prompt, name="01-initial-running", context_id="", task_id="")
+ observed_streams = _wait_for_with_intervening_ask_inputs(
+ h,
+ [initial],
+ _candidate_started,
+ description="candidate started before image interrupt",
+ timeout=args.event_timeout,
+ name_prefix="initial-running",
+ )
+ rollback = h.start_stream_image_text(
+ text=ROLLBACK_PROMPT,
+ image_key="rollback-interrupt",
+ name="02-rollback-image-interrupt",
+ )
+ _wait_any(
+ [*observed_streams, rollback],
+ _event_type("rollback_completed"),
+ description="image rollback_completed",
+ timeout=args.event_timeout,
+ )
+ streams_to_join = [*observed_streams, rollback]
+ _wait_any(
+ [*observed_streams, rollback],
+ _step_started("intent_parsing"),
+ description="post-image-rollback step_started(intent_parsing)",
+ timeout=args.event_timeout,
+ )
+ h.fetch_state("before-kill")
+ h.kill9_and_restart()
+ for stream in streams_to_join:
+ _join_after_kill(stream, h)
+ snapshot = h.fetch_state("after-restart")
+ h.checks["state endpoint returned snapshot after image interrupt restart"] = _snapshot(snapshot) is not None
+ resumed = h.stream(prompt=CONTINUE_PROMPT, name="03-continue-after-restart")
+ _finish_pipeline_after_possible_input(h, resumed, args)
+ h.checks["pipeline completed after image interrupt recovery"] = _completed_snapshot_or_stream(h, resumed)
+ final_state = h.fetch_state("after-image-interrupt-completion")
+ final_deploying = _final_deployment_evidence(final_state)
+ h.checks["final deploying target is security group"] = _has_any_marker(
+ final_deploying,
+ SECURITY_GROUP_MARKERS,
+ )
+ h.checks["final deploying target is not VSwitch"] = not _has_any_marker(final_deploying, VSWITCH_MARKERS)
+
+ return _run_with_harness(args, scenario, callback)
+
+
def run_rollback(args: argparse.Namespace, scenario: str) -> int:
target_step = _ROLLBACK_SCENARIOS[scenario]
@@ -868,11 +1231,14 @@ def callback(h: ScenarioHarness) -> None:
_add_hydrated_task_checks(h, resumed, "continue")
_finish_pipeline_after_possible_input(h, resumed, args)
after_continue = h.capture_task_snapshots("after-continue")
- h.checks["task_get_after_continue_completed"] = _task_response_matches(
- after_continue["task_get"],
- task_id=h.pipeline_task_id,
- context_id=h.context_id,
- ) and _task_status_state(after_continue["task_get"]) == "TASK_STATE_COMPLETED"
+ h.checks["task_get_after_continue_completed"] = (
+ _task_response_matches(
+ after_continue["task_get"],
+ task_id=h.pipeline_task_id,
+ context_id=h.context_id,
+ )
+ and _task_status_state(after_continue["task_get"]) == "TASK_STATE_COMPLETED"
+ )
h.checks["task_list_after_continue_kept_recovered_task"] = _task_list_contains(
after_continue["task_list"],
task_id=h.pipeline_task_id,
@@ -884,6 +1250,138 @@ def callback(h: ScenarioHarness) -> None:
return _run_with_harness(args, scenario, callback)
+def run_rollback_step5_cleanup(args: argparse.Namespace, scenario: str) -> int:
+ return _run_rollback_step5_cleanup(args, scenario, kill_during_cleanup=False)
+
+
+def run_rollback_step5_cleanup_recovery(args: argparse.Namespace, scenario: str) -> int:
+ return _run_rollback_step5_cleanup(args, scenario, kill_during_cleanup=True)
+
+
+def _run_rollback_step5_cleanup(
+ args: argparse.Namespace,
+ scenario: str,
+ *,
+ kill_during_cleanup: bool,
+) -> int:
+ def callback(h: ScenarioHarness) -> None:
+ initial = h.stream(prompt=args.initial_prompt, name="01-initial", context_id="", task_id="")
+ initial = _answer_intervening_ask_inputs(h, initial, name_prefix="01-initial")
+ h.checks["initial reached step4 selection"] = initial.last_input_required_step_id == "confirm_and_select"
+
+ first_deploy = h.start_stream(
+ prompt=_cleanup_deployment_prompt(args.selection_prompt, h, "first"),
+ name="02-create-first-stack",
+ )
+ first_stack_id = _wait_for_created_stack(
+ first_deploy,
+ exclude=set(),
+ timeout=args.event_timeout,
+ )
+ h.checks["first rollback stack observed before rollback"] = bool(first_stack_id)
+
+ rollback = h.start_stream(prompt=ROLLBACK_PROMPT, name="03-rollback-after-first-stack")
+ _wait_any(
+ [first_deploy, rollback],
+ _event_type("rollback_completed"),
+ description="rollback_completed after first stack",
+ timeout=args.event_timeout,
+ )
+ _wait_any(
+ [first_deploy, rollback],
+ _input_required_step("confirm_and_select"),
+ description="post-rollback input_required(confirm_and_select)",
+ timeout=_post_rollback_timeout(args),
+ )
+ cleanup_stack_ids = _cleanup_target_stack_ids(h, exclude=set())
+ h.checks["rollback cleanup ledger includes first stack"] = bool(first_stack_id) and (
+ first_stack_id in cleanup_stack_ids
+ )
+ h.checks["rollback cleanup target stacks observed"] = bool(cleanup_stack_ids)
+
+ second_deploy = h.start_stream(
+ prompt=_cleanup_deployment_prompt(args.selection_prompt, h, "second"),
+ name="04-select-second-stack",
+ )
+ _wait_any(
+ [second_deploy],
+ _step_started("deploying"),
+ description="second deployment step_started(deploying)",
+ timeout=args.event_timeout,
+ )
+ for stream in (first_deploy, rollback, second_deploy):
+ _join_stream_or_note(stream, h)
+
+ _finish_pipeline_after_possible_input(h, second_deploy.summary, args)
+ h.checks["pipeline completed after second deployment"] = _completed_snapshot_or_stream(h, second_deploy.summary)
+ h.fetch_state("after-second-stack")
+ second_stack_id = _created_stack_id_from_stream(second_deploy, exclude=set(cleanup_stack_ids))
+ h.checks["second stack created after rollback"] = bool(second_stack_id)
+ h.checks["second stack differs from first rollback stack"] = bool(second_stack_id) and (
+ second_stack_id != first_stack_id
+ )
+ cleanup_stack_ids = _cleanup_target_stack_ids(
+ h,
+ exclude={stack_id for stack_id in [second_stack_id] if stack_id},
+ )
+ h.checks["rollback cleanup ledger includes first stack"] = bool(first_stack_id) and (
+ first_stack_id in cleanup_stack_ids
+ )
+ h.checks["rollback cleanup target stacks observed"] = bool(cleanup_stack_ids)
+
+ if kill_during_cleanup:
+ cleanup_stream = h.start_stream(
+ prompt=args.normal_followup_prompt,
+ name="05-cleanup-running",
+ task_id="",
+ )
+ _wait_for_cleanup_started(h, cleanup_stream, first_stack_id, timeout=args.event_timeout)
+ h.kill9_and_restart()
+ _join_after_kill(cleanup_stream, h)
+ h.snapshots["after_cleanup_restart"] = h.fetch_state("after-cleanup-restart")
+ cleanup_summary = h.stream(prompt=CLEANUP_RECOVERY_PROMPT, name="06-cleanup-after-restart", task_id="")
+ h.checks["cleanup retriggered after restart"] = _events_file_has_cleanup_event(
+ h.run_dir / "06-cleanup-after-restart.events.jsonl",
+ stack_id=first_stack_id,
+ event_types={"cleanup_started", "cleanup_progress", "cleanup_completed"},
+ )
+ else:
+ cleanup_summary = h.stream(
+ prompt=args.normal_followup_prompt,
+ name="05-cleanup-normal-turn",
+ task_id="",
+ )
+ h.checks["cleanup normal turn stayed in same context"] = cleanup_summary.context_id == h.context_id
+ h.checks["cleanup normal turn used normal task"] = cleanup_summary.task_id != h.pipeline_task_id
+
+ after_cleanup = h.fetch_state("after-cleanup")
+ cleanup_resource = _cleanup_resource_for_stack(after_cleanup, first_stack_id)
+ h.checks["first rollback stack cleanup completed in snapshot"] = _cleanup_resource_completed(cleanup_resource)
+ h.checks["rollback cleanup stacks completed in snapshot"] = bool(cleanup_stack_ids) and all(
+ _cleanup_resource_completed(_cleanup_resource_for_stack(after_cleanup, stack_id))
+ for stack_id in cleanup_stack_ids
+ )
+ h.checks["cleanup snapshot does not target second stack"] = (
+ bool(second_stack_id) and _cleanup_resource_for_stack(after_cleanup, second_stack_id) is None
+ )
+
+ ros_stack_ids = _unique_strings([*cleanup_stack_ids, second_stack_id])
+ ros_states = _capture_ros_stack_states(
+ h,
+ ros_stack_ids,
+ "after-cleanup",
+ )
+ h.checks["ROS first rollback stack deleted"] = _ros_stack_deleted(ros_states.get(first_stack_id, {}))
+ h.checks["ROS rollback cleanup stacks deleted"] = bool(cleanup_stack_ids) and all(
+ _ros_stack_deleted(ros_states.get(stack_id, {})) for stack_id in cleanup_stack_ids
+ )
+ h.checks["ROS second stack retained"] = bool(second_stack_id) and _ros_stack_retained(
+ ros_states.get(second_stack_id, {})
+ )
+
+ return _run_with_harness(args, scenario, callback)
+
+
def _complete_pipeline(h: ScenarioHarness, args: argparse.Namespace) -> None:
initial = h.stream(prompt=args.initial_prompt, name="01-initial", context_id="", task_id="")
initial = _answer_intervening_ask_inputs(h, initial, name_prefix="01-initial")
@@ -985,10 +1483,7 @@ def predicate(event: Any, _summary: StreamSummary) -> bool:
return (
isinstance(envelope, dict)
and envelope.get("eventType") == "input_required"
- and (
- (isinstance(step, dict) and step.get("id") == step_id)
- or data_step_id == step_id
- )
+ and ((isinstance(step, dict) and step.get("id") == step_id) or data_step_id == step_id)
)
return predicate
@@ -1023,14 +1518,18 @@ def _wait_any(
) -> EventMatch:
deadline = time.monotonic() + timeout
last_error = ""
+ active_streams = list(streams)
while time.monotonic() < deadline:
- for stream in streams:
+ for stream in list(active_streams):
try:
return stream.wait_for(predicate, description=description, timeout=0.25)
except TimeoutError:
continue
except RuntimeError as exc:
last_error = str(exc)
+ active_streams.remove(stream)
+ if not active_streams:
+ break
time.sleep(0.05)
raise TimeoutError(f"Timed out waiting for {description}; last_error={last_error}")
@@ -1067,9 +1566,7 @@ def _wait_for_with_intervening_ask_inputs(
answered_count += 1
if answered_count > 4:
raise RuntimeError(f"too many intervening ask_user_question inputs before {description}") from exc
- h.notes.append(
- f"answered intervening ask_user_question while waiting for {description}: {stream.name}"
- )
+ h.notes.append(f"answered intervening ask_user_question while waiting for {description}: {stream.name}")
answer = h.start_stream(
prompt=INTERVENING_ASK_ANSWER,
name=f"{name_prefix}-answer-ask-{answered_count}",
@@ -1265,11 +1762,7 @@ def _jsonrpc_result(response: Any) -> Any:
def _task_response_matches(response: Any, *, task_id: str, context_id: str) -> bool:
result = _jsonrpc_result(response)
identity = _a2a_task_identity(result)
- return (
- isinstance(identity, dict)
- and identity.get("taskId") == task_id
- and identity.get("contextId") == context_id
- )
+ return isinstance(identity, dict) and identity.get("taskId") == task_id and identity.get("contextId") == context_id
def _task_status_state(response: Any) -> str:
@@ -1286,11 +1779,7 @@ def _task_list_contains(response: Any, *, task_id: str, context_id: str) -> bool
return False
for task in tasks:
identity = _a2a_task_identity(task)
- if (
- isinstance(identity, dict)
- and identity.get("taskId") == task_id
- and identity.get("contextId") == context_id
- ):
+ if isinstance(identity, dict) and identity.get("taskId") == task_id and identity.get("contextId") == context_id:
return True
return False
@@ -1420,6 +1909,560 @@ def _has_any_marker(text: str, markers: Iterable[str]) -> bool:
return any(marker in text for marker in markers)
+def _join_stream_or_note(stream: BackgroundStream, h: ScenarioHarness) -> None:
+ try:
+ stream.join(timeout=h.args.stream_timeout)
+ except Exception as exc:
+ h.notes.append(f"{stream.name} ended while joining: {type(exc).__name__}: {exc}")
+
+
+def _post_rollback_timeout(args: argparse.Namespace) -> float:
+ event_timeout = float(getattr(args, "event_timeout", 0) or 0)
+ stream_timeout = float(getattr(args, "stream_timeout", 0) or 0)
+ return max(event_timeout, min(stream_timeout, 900.0))
+
+
+def _cleanup_deployment_prompt(base_prompt: str, h: ScenarioHarness, label: str) -> str:
+ stack_name = _cleanup_stack_name(h, label)
+ completion_instruction = (
+ "本轮是回滚窗口验证:CreateStack 成功后不要调用 complete_step,不要结束 deploying step;"
+ "只简短说明新建的 stack_id,并等待用户下一条指令。"
+ if label == "first"
+ else "complete_step 前必须在本轮对话中看到一次新的 CreateStack 成功,部署总结的 stack_id 必须来自这次新建。"
+ )
+ return (
+ f"{base_prompt}\n\n"
+ "E2E 强制部署约束:\n"
+ f"- 本轮唯一成功条件是新建一个 ROS stack,StackName 必须精确等于 `{stack_name}`。\n"
+ "- 任何已有 stack(即使是 CREATE_COMPLETE)都必须视为失败结果,不能作为部署成功依据。\n"
+ f"- 调用 ros_stack 或 aliyun_api CreateStack 前,必须复核工具参数里的 StackName 精确等于 `{stack_name}`。\n"
+ f"- 如果模板、文件名、候选方案或默认值给出了其他 StackName,必须覆盖为 `{stack_name}` 后再调用 CreateStack。\n"
+ f"- 如果已经用其他 StackName 调用失败,不能 GetStack 或复用那个 stack,必须改用 `{stack_name}` "
+ "重新 CreateStack。\n"
+ "- 如果无法使用上述 StackName 新建 stack,就停下来说明失败,不要调用 complete_step。\n"
+ f"{completion_instruction}"
+ "创建 VSwitch 时请先检查目标 VPC 已有 VSwitch CIDR,选择未占用且属于 VPC CIDR 的网段;"
+ "如果 CIDR 冲突,请选择另一个未占用网段并继续使用上述指定 StackName。"
+ )
+
+
+def _cleanup_stack_name(h: ScenarioHarness, label: str) -> str:
+ suffix = Path(getattr(h, "run_dir", "")).name.rsplit("-", maxsplit=1)[-1] or "stack"
+ safe_label = "".join(ch if ch.isalnum() else "-" for ch in label.lower()).strip("-") or "stack"
+ return f"iac-e2e-{suffix[:12]}-{safe_label}"[:128]
+
+
+def _wait_for_observed_cleanup_stack(
+ h: ScenarioHarness,
+ *,
+ exclude: set[str],
+ timeout: float,
+) -> str:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ stack_id = _latest_observed_stack_id(h, exclude=exclude)
+ if stack_id:
+ return stack_id
+ time.sleep(1.0)
+ raise TimeoutError("Timed out waiting for rollback cleanup ledger to observe a ROS stack")
+
+
+def _wait_for_created_stack(
+ stream: BackgroundStream,
+ *,
+ exclude: set[str],
+ timeout: float,
+) -> str:
+ match = _wait_any(
+ [stream],
+ _created_stack_event(exclude),
+ description="successful CreateStack stack_current_changed",
+ timeout=timeout,
+ )
+ envelope = _extract_pipeline_envelope(match.event)
+ data = envelope.get("data") if isinstance(envelope, dict) else None
+ stack_id = _string_from_mapping(data, "stackId", "stack_id", "StackId")
+ if not stack_id:
+ raise RuntimeError("successful CreateStack event did not include a stack id")
+ return stack_id
+
+
+def _created_stack_id_from_stream(stream: Any, *, exclude: set[str]) -> str | None:
+ for event in getattr(stream, "events", []) or []:
+ envelope = _extract_pipeline_envelope(event)
+ if not isinstance(envelope, dict) or envelope.get("eventType") != "stack_current_changed":
+ continue
+ data = envelope.get("data")
+ if not isinstance(data, dict):
+ continue
+ if str(data.get("provider") or "").lower() != "ros":
+ continue
+ if data.get("action") != "CreateStack" or data.get("isSuccess") is not True:
+ continue
+ stack_id = _string_from_mapping(data, "stackId", "stack_id", "StackId")
+ if stack_id and stack_id not in exclude:
+ return stack_id
+ return None
+
+
+def _created_stack_event(exclude: set[str]) -> Callable[[Any, StreamSummary], bool]:
+ def predicate(event: Any, _summary: StreamSummary) -> bool:
+ envelope = _extract_pipeline_envelope(event)
+ if not isinstance(envelope, dict) or envelope.get("eventType") != "stack_current_changed":
+ return False
+ data = envelope.get("data")
+ if not isinstance(data, dict):
+ return False
+ if str(data.get("provider") or "").lower() != "ros":
+ return False
+ if data.get("action") != "CreateStack" or data.get("isSuccess") is not True:
+ return False
+ stack_id = _string_from_mapping(data, "stackId", "stack_id", "StackId")
+ return bool(stack_id and stack_id not in exclude)
+
+ return predicate
+
+
+def _latest_observed_stack_id(h: ScenarioHarness, *, exclude: set[str]) -> str | None:
+ resources = _cleanup_ledger_items(h, "observed_resources")
+ for resource in reversed(resources):
+ if not _is_ros_stack_resource(resource):
+ continue
+ if str(resource.get("observed_action") or resource.get("action") or "") != "CreateStack":
+ continue
+ stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ if stack_id and stack_id not in exclude:
+ return stack_id
+ return None
+
+
+def _cleanup_ledger_items(h: ScenarioHarness, key: str) -> list[dict[str, Any]]:
+ if not getattr(h, "context_id", ""):
+ return []
+ try:
+ from iac_code.services.session_storage import SessionStorage
+
+ cwd, session_id = _pipeline_session_identity(h)
+ session_dir = SessionStorage().session_dir(cwd, session_id)
+ paths = [session_dir / "pipeline" / "cleanup.yaml", session_dir / "a2a" / "pipeline" / "cleanup.yaml"]
+ data = None
+ for path in paths:
+ if path.exists():
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ break
+ except (OSError, UnicodeDecodeError, yaml.YAMLError):
+ return []
+ if not isinstance(data, dict):
+ return []
+ values = data.get(key)
+ return [item for item in values if isinstance(item, dict)] if isinstance(values, list) else []
+
+
+def _pipeline_session_identity(h: ScenarioHarness) -> tuple[str, str]:
+ context_id = str(getattr(h, "context_id", "") or "")
+ cwd = str(getattr(h, "cwd", "") or "")
+ run_dir_value = getattr(h, "run_dir", None)
+ if context_id and run_dir_value is not None:
+ path = Path(run_dir_value) / "a2a-persistence" / "contexts" / f"{context_id}.json"
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
+ data = None
+ if isinstance(data, dict):
+ session_id = data.get("session_id")
+ persisted_cwd = data.get("cwd")
+ if isinstance(session_id, str) and session_id:
+ return (persisted_cwd if isinstance(persisted_cwd, str) and persisted_cwd else cwd, session_id)
+ return cwd, context_id
+
+
+def _wait_for_cleanup_started(
+ h: ScenarioHarness,
+ stream: BackgroundStream,
+ stack_id: str,
+ *,
+ timeout: float,
+) -> None:
+ try:
+ _wait_any(
+ [stream],
+ _cleanup_event_for_stack(stack_id, {"cleanup_started", "cleanup_progress"}),
+ description=f"cleanup_started({stack_id})",
+ timeout=timeout,
+ )
+ return
+ except Exception as exc:
+ h.notes.append(f"did not observe cleanup_started event before fallback: {exc}")
+ _wait_for_cleanup_ledger_status(h, stack_id, {"started", "in_progress"}, timeout=timeout)
+
+
+def _wait_for_cleanup_ledger_status(
+ h: ScenarioHarness,
+ stack_id: str,
+ statuses: set[str],
+ *,
+ timeout: float,
+) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ for resource in _cleanup_ledger_items(h, "cleanup_resources"):
+ if _string_from_mapping(resource, "resource_id", "resourceId") != stack_id:
+ continue
+ if str(resource.get("cleanup_status") or resource.get("cleanupStatus") or "") in statuses:
+ return
+ time.sleep(0.5)
+ raise TimeoutError(f"Timed out waiting for cleanup ledger status {sorted(statuses)} on {stack_id}")
+
+
+def _cleanup_event_for_stack(
+ stack_id: str,
+ event_types: set[str],
+) -> Callable[[Any, StreamSummary], bool]:
+ def predicate(event: Any, _summary: StreamSummary) -> bool:
+ envelope = _extract_pipeline_envelope(event)
+ if not isinstance(envelope, dict) or envelope.get("eventType") not in event_types:
+ return False
+ data = envelope.get("data")
+ return isinstance(data, dict) and data.get("resourceId") == stack_id
+
+ return predicate
+
+
+def _events_file_has_cleanup_event(path: Path, *, stack_id: str, event_types: set[str]) -> bool:
+ try:
+ lines = path.read_text(encoding="utf-8").splitlines()
+ except OSError:
+ return False
+ for line in lines:
+ try:
+ value = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ envelope = _extract_pipeline_envelope(value)
+ if not isinstance(envelope, dict) or envelope.get("eventType") not in event_types:
+ continue
+ data = envelope.get("data")
+ if isinstance(data, dict) and data.get("resourceId") == stack_id:
+ return True
+ return False
+
+
+def _run_dir_has_cleanup_events(run_dir: Path) -> bool:
+ return any(_events_file_has_cleanup_activity(path) for path in sorted(run_dir.glob("*.events.jsonl")))
+
+
+def _events_file_has_cleanup_activity(path: Path) -> bool:
+ try:
+ lines = path.read_text(encoding="utf-8").splitlines()
+ except OSError:
+ return False
+ for line in lines:
+ try:
+ value = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ envelope = _extract_pipeline_envelope(value)
+ if isinstance(envelope, dict) and _pipeline_envelope_has_cleanup_activity(envelope):
+ return True
+ return False
+
+
+def _pipeline_envelope_has_cleanup_activity(envelope: dict[str, Any]) -> bool:
+ if envelope.get("eventType") in CLEANUP_EVENT_TYPES or envelope.get("scope") == "cleanup":
+ return True
+ data = envelope.get("data")
+ cleanup = data.get("cleanup") if isinstance(data, dict) else None
+ return isinstance(cleanup, dict) and _cleanup_payload_has_targets(cleanup)
+
+
+def _cleanup_resource_for_stack(response: Any, stack_id: str | None) -> dict[str, Any] | None:
+ if not stack_id:
+ return None
+ cleanup = _snapshot_cleanup(response)
+ resources = cleanup.get("resources") if isinstance(cleanup, dict) else None
+ if not isinstance(resources, list):
+ return None
+ for resource in resources:
+ if isinstance(resource, dict) and resource.get("resourceId") == stack_id:
+ return resource
+ return None
+
+
+def _cleanup_target_stack_ids(h: ScenarioHarness, *, exclude: set[str]) -> list[str]:
+ stack_ids: list[str] = []
+ for resource in _cleanup_ledger_items(h, "cleanup_resources"):
+ if not _is_ros_stack_resource(resource):
+ continue
+ if resource.get("cleanup_required") is False or resource.get("cleanupRequired") is False:
+ continue
+ stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ if stack_id and stack_id not in exclude:
+ stack_ids.append(stack_id)
+ return _unique_strings(stack_ids)
+
+
+def _cleanup_resource_completed(resource: dict[str, Any] | None) -> bool:
+ if not isinstance(resource, dict):
+ return False
+ cleanup_status = resource.get("cleanupStatus") or resource.get("cleanup_status") or resource.get("status")
+ stack_status = resource.get("stackStatus") or resource.get("progressStatus") or resource.get("progress_status")
+ return cleanup_status == "completed" and stack_status == "DELETE_COMPLETE"
+
+
+def _snapshot_cleanup(response: Any) -> dict[str, Any]:
+ snapshot = _snapshot(response)
+ cleanup = snapshot.get("cleanup") if isinstance(snapshot, dict) else None
+ return cleanup if isinstance(cleanup, dict) else {}
+
+
+def _snapshot_has_cleanup_activity(response: Any) -> bool:
+ return _cleanup_payload_has_targets(_snapshot_cleanup(response))
+
+
+def _cleanup_payload_has_targets(cleanup: dict[str, Any]) -> bool:
+ resources = cleanup.get("resources")
+ if isinstance(resources, list) and any(isinstance(item, dict) for item in resources):
+ return True
+ history = cleanup.get("history")
+ if isinstance(history, list) and history:
+ return True
+ resource_count = cleanup.get("resourceCount", cleanup.get("resource_count"))
+ if _positive_int(resource_count):
+ return True
+ status = str(cleanup.get("status") or "")
+ return status in CLEANUP_ACTIVE_STATUSES
+
+
+def _positive_int(value: Any) -> bool:
+ if isinstance(value, bool):
+ return False
+ if isinstance(value, int):
+ return value > 0
+ if isinstance(value, str):
+ try:
+ return int(value) > 0
+ except ValueError:
+ return False
+ return False
+
+
+def _cleanup_ledger_has_required_resources(h: ScenarioHarness) -> bool:
+ for resource in _cleanup_ledger_items(h, "cleanup_resources"):
+ if resource.get("cleanup_required") is False or resource.get("cleanupRequired") is False:
+ continue
+ return True
+ return False
+
+
+def _session_has_cleanup_prompt(h: ScenarioHarness) -> bool:
+ if not getattr(h, "context_id", ""):
+ return False
+ try:
+ from iac_code.services.session_storage import SessionStorage
+
+ cwd, session_id = _pipeline_session_identity(h)
+ return _session_file_has_cleanup_prompt(SessionStorage().session_path(cwd, session_id))
+ except OSError:
+ return False
+
+
+def _session_file_has_cleanup_prompt(path: Path) -> bool:
+ try:
+ lines = path.read_text(encoding="utf-8").splitlines()
+ except (OSError, UnicodeDecodeError):
+ return False
+ for line in lines:
+ try:
+ value = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ metadata = value.get("metadata") if isinstance(value, dict) else None
+ if isinstance(metadata, dict) and metadata.get("type") == CLEANUP_PROMPT_METADATA_TYPE:
+ return True
+ return False
+
+
+def _snapshot_current_stack_id(response: Any, *, exclude: set[str]) -> str | None:
+ snapshot = _snapshot(response)
+ stacks = snapshot.get("stacks") if isinstance(snapshot, dict) else None
+ if not isinstance(stacks, dict):
+ return None
+ current = stacks.get("current")
+ current_id = _active_stack_id_from_record(current)
+ if current_id and current_id not in exclude:
+ return current_id
+ by_id = stacks.get("byId")
+ if isinstance(by_id, dict):
+ for record in reversed(list(by_id.values())):
+ stack_id = _active_stack_id_from_record(record)
+ if stack_id and stack_id not in exclude:
+ return stack_id
+ history = stacks.get("history")
+ if isinstance(history, list):
+ for record in reversed(history):
+ stack_id = _active_stack_id_from_record(record)
+ if stack_id and stack_id not in exclude:
+ return stack_id
+ return None
+
+
+def _active_stack_id_from_record(record: Any) -> str | None:
+ if not isinstance(record, dict):
+ return None
+ if record.get("current") is False or record.get("cleared") is True:
+ return None
+ if record.get("isSuccess") is False:
+ return None
+ status = str(record.get("stackStatus") or record.get("status") or "")
+ if status.endswith("_FAILED"):
+ return None
+ action = record.get("action")
+ if action == "DeleteStack":
+ return None
+ return _string_from_mapping(record, "stackId", "stack_id", "StackId", "id")
+
+
+def _capture_ros_stack_states(h: ScenarioHarness, stack_ids: Iterable[str], name: str) -> dict[str, dict[str, Any]]:
+ states: dict[str, dict[str, Any]] = {}
+ for stack_id in stack_ids:
+ region_id = _region_for_stack(h, stack_id)
+ states[stack_id] = _get_ros_stack_state(stack_id=stack_id, region_id=region_id, redaction_env=h.server_env)
+ redacted = _redact_json_value(states, h.server_env)
+ _write_json(h.run_dir / f"{name}.ros-stack-states.json", redacted)
+ h.snapshots[f"{name}.ros-stack-states"] = redacted
+ return states
+
+
+def _get_ros_stack_state(
+ *,
+ stack_id: str,
+ region_id: str,
+ redaction_env: dict[str, str] | None,
+) -> dict[str, Any]:
+ try:
+ from alibabacloud_ros20190910 import models as ros_models
+
+ from iac_code.services.cloud_credentials import CloudCredentials
+ from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory
+
+ credential = CloudCredentials().get_provider("aliyun")
+ effective_region = region_id or (credential.region_id if credential is not None else "")
+ client = RosClientFactory.create(credential, effective_region)
+ request = ros_models.GetStackRequest(stack_id=stack_id, region_id=effective_region)
+ response = client.get_stack(request)
+ body = response.body.to_map()
+ return {
+ "stack_id": str(body.get("StackId") or stack_id),
+ "stack_name": str(body.get("StackName") or ""),
+ "region_id": effective_region,
+ "status": str(body.get("Status") or ""),
+ "status_reason": str(body.get("StatusReason") or ""),
+ "not_found": False,
+ }
+ except Exception as exc:
+ message = _redact_sensitive_text(str(exc), redaction_env)
+ return {
+ "stack_id": stack_id,
+ "region_id": region_id,
+ "status": "",
+ "not_found": _is_ros_stack_not_found(exc),
+ "error": _compact_text(message, max_chars=1000),
+ }
+
+
+def _is_ros_stack_not_found(exc: BaseException) -> bool:
+ code = str(getattr(exc, "code", "") or "")
+ message = str(exc)
+ combined = f"{code} {message}".lower()
+ not_found_tokens = (
+ "stacknotfound",
+ "notfound.stack",
+ "entitynotexist.stack",
+ "specified stack does not exist",
+ "stack could not be found",
+ "stack not found",
+ )
+ return any(token in combined for token in not_found_tokens)
+
+
+def _region_for_stack(h: ScenarioHarness, stack_id: str) -> str:
+ for snapshot in reversed(list(h.snapshots.values())):
+ region = _region_for_stack_in_snapshot(snapshot, stack_id)
+ if region:
+ return region
+ for key in ("cleanup_resources", "observed_resources"):
+ for resource in reversed(_cleanup_ledger_items(h, key)):
+ if _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") == stack_id:
+ region = _string_from_mapping(resource, "region_id", "regionId", "RegionId")
+ if region:
+ return region
+ return h.server_env.get("ALIBABA_CLOUD_REGION_ID", "")
+
+
+def _region_for_stack_in_snapshot(response: Any, stack_id: str) -> str:
+ cleanup_resource = _cleanup_resource_for_stack(response, stack_id)
+ if cleanup_resource is not None:
+ region = _string_from_mapping(cleanup_resource, "regionId", "region_id", "RegionId")
+ if region:
+ return region
+ snapshot = _snapshot(response)
+ stacks = snapshot.get("stacks") if isinstance(snapshot, dict) else None
+ if not isinstance(stacks, dict):
+ return ""
+ by_id = stacks.get("byId")
+ if isinstance(by_id, dict):
+ record = by_id.get(stack_id)
+ region = _string_from_mapping(record, "regionId", "region_id", "RegionId") if isinstance(record, dict) else None
+ if region:
+ return region
+ current = stacks.get("current")
+ if isinstance(current, dict) and _string_from_mapping(current, "stackId", "stack_id", "StackId") == stack_id:
+ return _string_from_mapping(current, "regionId", "region_id", "RegionId") or ""
+ return ""
+
+
+def _ros_stack_deleted(state: dict[str, Any]) -> bool:
+ if not isinstance(state, dict):
+ return False
+ if state.get("not_found") is True:
+ return True
+ return state.get("status") in ROS_STACK_DELETED_STATUSES
+
+
+def _ros_stack_retained(state: dict[str, Any]) -> bool:
+ if not isinstance(state, dict) or state.get("not_found") is True:
+ return False
+ status = state.get("status")
+ return isinstance(status, str) and bool(status) and not status.startswith("DELETE_")
+
+
+def _is_ros_stack_resource(resource: dict[str, Any]) -> bool:
+ provider = str(resource.get("provider") or "").lower()
+ resource_type = str(resource.get("resource_type") or resource.get("resourceType") or "").lower()
+ return provider == "ros" and resource_type == "stack"
+
+
+def _unique_strings(values: Iterable[str | None]) -> list[str]:
+ result: list[str] = []
+ seen: set[str] = set()
+ for value in values:
+ if not isinstance(value, str) or not value or value in seen:
+ continue
+ seen.add(value)
+ result.append(value)
+ return result
+
+
+def _string_from_mapping(mapping: Any, *keys: str) -> str | None:
+ if not isinstance(mapping, dict):
+ return None
+ for key in keys:
+ value = mapping.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
def _scenario_run_dir(args: argparse.Namespace, scenario: str) -> Path:
if args.run_dir:
return Path(args.run_dir).expanduser()
@@ -1477,20 +2520,34 @@ def _validate_scenario_execution(args: argparse.Namespace, scenario: str) -> Non
}
_REAL_CLOUD_SCENARIOS = {
"fault-after-snapshot",
+ "image-ask-waiting",
+ "image-initial",
+ "image-interrupt",
+ "image-normal-handoff",
+ "image-selection-waiting",
"scenario1",
"normal-running",
"ask-waiting",
"selection-waiting",
+ "rollback-step5-cleanup",
+ "rollback-step5-cleanup-recovery",
*_RUNNING_STEP_SCENARIOS,
*_ROLLBACK_SCENARIOS,
*_CANCEL_SCENARIOS,
}
_SCENARIOS: dict[str, Callable[[argparse.Namespace, str], int]] = {
+ "image-ask-waiting": run_image_ask_waiting,
+ "image-initial": run_image_initial,
+ "image-interrupt": run_image_interrupt,
+ "image-normal-handoff": run_image_normal_handoff,
+ "image-selection-waiting": run_image_selection_waiting,
"scenario1": run_scenario1,
"normal-running": run_normal_running,
"ask-waiting": run_ask_waiting,
"selection-waiting": run_selection_waiting,
"fault-after-snapshot": run_fault_after_snapshot,
+ "rollback-step5-cleanup": run_rollback_step5_cleanup,
+ "rollback-step5-cleanup-recovery": run_rollback_step5_cleanup_recovery,
**{name: run_running_step for name in _RUNNING_STEP_SCENARIOS},
**{name: run_rollback for name in _ROLLBACK_SCENARIOS},
**{name: run_cancel for name in _CANCEL_SCENARIOS},
diff --git a/scripts/a2a/selling_console.py b/scripts/a2a/selling_console.py
new file mode 100644
index 00000000..80805a26
--- /dev/null
+++ b/scripts/a2a/selling_console.py
@@ -0,0 +1,294 @@
+"""Local web console for A2A selling pipelines.
+
+The bundled web UI currently sends text input only; use the A2A debugger for
+image-part request coverage.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import html
+import importlib
+import json
+import os
+import sys
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlparse
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+a2a_debugger = importlib.import_module("scripts.a2a.debugger")
+
+WEB_ROOT = Path(__file__).resolve().with_name("selling_console_web")
+TEMPLATE_PLACEHOLDERS = (
+ "__DEFAULTS_JSON__",
+ "__DEFAULT_SERVER_URL_ATTR__",
+ "__DEFAULT_CWD_ATTR__",
+ "__STATIC_ASSET_VERSION__",
+)
+
+
+@dataclass(frozen=True)
+class StaticAsset:
+ path: Path
+ content_type: str
+
+
+STYLE_ASSET = StaticAsset(WEB_ROOT / "styles.css", "text/css; charset=utf-8")
+APP_ASSET = StaticAsset(WEB_ROOT / "app.js", "application/javascript; charset=utf-8")
+STATIC_ASSETS = (STYLE_ASSET, APP_ASSET)
+
+
+@dataclass(frozen=True)
+class SellingConsoleConfig:
+ host: str
+ port: int
+ default_server_url: str
+ default_cwd: str
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run a local A2A selling pipeline console.")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=41980)
+ parser.add_argument("--default-server-url", default="http://127.0.0.1:41299")
+ parser.add_argument("--default-cwd", default=os.getcwd())
+ return parser.parse_args(argv)
+
+
+def _html_attribute_value(value: str) -> str:
+ escaped = html.escape(value, quote=True)
+ for placeholder in TEMPLATE_PLACEHOLDERS:
+ escaped = escaped.replace(placeholder, placeholder.replace("_", "_"))
+ return escaped
+
+
+def _json_for_template(value: object) -> str:
+ json_value = a2a_debugger._json_for_script(value)
+ for placeholder in TEMPLATE_PLACEHOLDERS:
+ json_value = json_value.replace(placeholder, placeholder.replace("_", "\\u005f"))
+ return json_value
+
+
+def _static_asset_version() -> str:
+ digest = hashlib.sha256()
+ for asset in STATIC_ASSETS:
+ digest.update(asset.path.name.encode("utf-8"))
+ digest.update(asset.path.read_bytes())
+ return digest.hexdigest()[:12]
+
+
+def render_index_html(config: SellingConsoleConfig) -> str:
+ defaults_json = _json_for_template(
+ {
+ "serverUrl": config.default_server_url,
+ "cwd": config.default_cwd,
+ }
+ )
+ return (
+ (WEB_ROOT / "index.html")
+ .read_text(encoding="utf-8")
+ .replace("__DEFAULT_SERVER_URL_ATTR__", _html_attribute_value(config.default_server_url))
+ .replace("__DEFAULT_CWD_ATTR__", _html_attribute_value(config.default_cwd))
+ .replace("__DEFAULTS_JSON__", defaults_json)
+ .replace("__STATIC_ASSET_VERSION__", _static_asset_version())
+ )
+
+
+def _send_text(handler: BaseHTTPRequestHandler, status: int, body: str, content_type: str) -> None:
+ raw_body = body.encode("utf-8")
+ handler.send_response(status)
+ handler.send_header("Content-Type", content_type)
+ handler.send_header("Content-Length", str(len(raw_body)))
+ handler.end_headers()
+ handler.wfile.write(raw_body)
+
+
+def _static_asset_for_request(path: str) -> StaticAsset | None:
+ if path == "/styles.css":
+ return STYLE_ASSET
+ if path == "/app.js":
+ return APP_ASSET
+ return None
+
+
+def _send_static(handler: BaseHTTPRequestHandler, path: str) -> bool:
+ asset = _static_asset_for_request(path)
+ if asset is None:
+ return False
+ web_root = WEB_ROOT.resolve()
+ candidate = asset.path.resolve()
+ try:
+ candidate.relative_to(web_root)
+ except ValueError:
+ return False
+ if not candidate.is_file():
+ return False
+ _send_text(handler, 200, candidate.read_text(encoding="utf-8"), asset.content_type)
+ return True
+
+
+def _proxy_error_body(exc: BaseException) -> dict[str, object]:
+ if isinstance(exc, HTTPError):
+ raw = exc.read()
+ data, text = a2a_debugger._decode_json_text(raw)
+ return a2a_debugger._proxy_error(
+ a2a_debugger.ProxyResult(
+ status_code=exc.code,
+ data=data,
+ text=text,
+ headers=dict(exc.headers.items()),
+ error=f"HTTP {exc.code}",
+ )
+ )
+ return a2a_debugger._proxy_error(
+ a2a_debugger.ProxyResult(status_code=0, data=None, text="", headers={}, error=str(exc))
+ )
+
+
+def _write_sse_error_event(handler: BaseHTTPRequestHandler, message: str) -> None:
+ body = f"data: {json.dumps({'ok': False, 'error': message}, ensure_ascii=False)}\n\n".encode("utf-8")
+ try:
+ handler.wfile.write(body)
+ handler.wfile.flush()
+ except OSError:
+ return
+
+
+def create_server(config: SellingConsoleConfig) -> ThreadingHTTPServer:
+ class SellingConsoleHTTPServer(ThreadingHTTPServer):
+ allow_reuse_address = sys.platform != "win32"
+
+ class SellingConsoleHandler(BaseHTTPRequestHandler):
+ def log_message(self, format: str, *args: object) -> None:
+ return None
+
+ def do_GET(self) -> None:
+ parsed = urlparse(self.path)
+ try:
+ if parsed.path == "/":
+ _send_text(self, 200, render_index_html(config), "text/html; charset=utf-8")
+ return
+ if parsed.path == "/api/health":
+ status, body = a2a_debugger._health_response(
+ a2a_debugger._query_params(self.path).get("serverUrl", "")
+ )
+ a2a_debugger._send_json(self, status, body)
+ return
+ if parsed.path == "/api/pipeline/state":
+ status, body = a2a_debugger._pipeline_state_response(a2a_debugger._query_params(self.path))
+ a2a_debugger._send_json(self, status, body)
+ return
+ if parsed.path == "/api/task/get":
+ status, body = a2a_debugger._task_get_response(a2a_debugger._query_params(self.path))
+ a2a_debugger._send_json(self, status, body)
+ return
+ if _send_static(self, parsed.path):
+ return
+ except ValueError as exc:
+ a2a_debugger._send_json(self, 400, {"ok": False, "error": str(exc)})
+ return
+ except (HTTPError, URLError, TimeoutError, OSError) as exc:
+ a2a_debugger._send_json(self, 502, _proxy_error_body(exc))
+ return
+ a2a_debugger._send_json(self, 404, {"ok": False, "error": "Not found"})
+
+ def do_POST(self) -> None:
+ parsed = urlparse(self.path)
+ try:
+ if parsed.path == "/api/message/stream":
+ body = a2a_debugger._read_json_body(self)
+ server_url, payload = a2a_debugger._message_stream_body(body)
+ try:
+ with a2a_debugger._open_sse_stream(server_url, payload) as response:
+ headers = getattr(response, "headers", {})
+ content_type = ""
+ if hasattr(headers, "get"):
+ content_type = str(headers.get("Content-Type", "")).lower()
+ if content_type and "text/event-stream" not in content_type:
+ raw = response.read()
+ data, _text = a2a_debugger._decode_json_text(raw)
+ message = a2a_debugger._jsonrpc_error_message(data)
+ if message:
+ a2a_debugger._send_sse_event(
+ self,
+ 200,
+ {
+ "type": "error",
+ "error": message,
+ "statusCode": response.status,
+ "body": data,
+ },
+ )
+ return
+ a2a_debugger._send_sse_error(self, 502, "Target server returned a non-SSE response")
+ return
+ self.send_response(response.status)
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
+ self.end_headers()
+ response_iter = iter(response)
+ while True:
+ try:
+ line = next(response_iter)
+ except StopIteration:
+ break
+ except (TimeoutError, URLError, OSError) as exc:
+ _write_sse_error_event(self, str(exc))
+ return
+ try:
+ self.wfile.write(line)
+ self.wfile.flush()
+ except OSError as exc:
+ if a2a_debugger._is_client_disconnect_error(exc):
+ return
+ return
+ except HTTPError as exc:
+ a2a_debugger._send_sse_error(self, 502, f"HTTP {exc.code}")
+ except (TimeoutError, URLError, OSError) as exc:
+ a2a_debugger._send_sse_error(self, 502, str(exc))
+ return
+ if parsed.path == "/api/task/cancel":
+ body = a2a_debugger._read_json_body(self)
+ status, response_body = a2a_debugger._task_cancel_response(body)
+ a2a_debugger._send_json(self, status, response_body)
+ return
+ except ValueError as exc:
+ a2a_debugger._send_json(self, 400, {"ok": False, "error": str(exc)})
+ return
+ except (HTTPError, URLError, TimeoutError, OSError) as exc:
+ a2a_debugger._send_json(self, 502, _proxy_error_body(exc))
+ return
+ a2a_debugger._send_json(self, 404, {"ok": False, "error": "Not found"})
+
+ return SellingConsoleHTTPServer((config.host, config.port), SellingConsoleHandler)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv)
+ config = SellingConsoleConfig(
+ host=args.host,
+ port=args.port,
+ default_server_url=args.default_server_url,
+ default_cwd=args.default_cwd,
+ )
+ server = create_server(config)
+ host, port = server.server_address
+ print(f"Selling pipeline console listening on http://{host}:{port}")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ return 0
+ finally:
+ server.shutdown()
+ server.server_close()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/a2a/selling_console_web/README.md b/scripts/a2a/selling_console_web/README.md
new file mode 100644
index 00000000..029d2781
--- /dev/null
+++ b/scripts/a2a/selling_console_web/README.md
@@ -0,0 +1,34 @@
+# Selling Console Web
+
+Standalone static frontend for `scripts/a2a/selling_console.py`. It is used to drive the selling pipeline, inspect step progress, select candidate plans, and continue into normal chat after deployment.
+
+The web console currently sends text input only. Use `scripts/a2a/debugger.py` for A2A image-part coverage.
+
+## Run
+
+From the repository root, start the A2A server first:
+
+```bash
+PATH="$HOME/.local/bin:$PATH" IAC_CODE_MODE=pipeline \
+uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299
+```
+
+Then start the web console:
+
+```bash
+PATH="$HOME/.local/bin:$PATH" \
+uv run python scripts/a2a/selling_console.py --port 41980 \
+ --default-server-url http://127.0.0.1:41299 \
+ --default-cwd "$PWD"
+```
+
+Then open `http://127.0.0.1:41980`.
+
+## Files
+
+- `index.html` renders the page shell.
+- `styles.css` contains layout, chat, plan cards, and progress visuals.
+- `app.js` handles A2A stream parsing, UI state, debug controls, and interactions.
+- `design/` keeps standalone visual explorations for progress variants.
+
+The debug panel is collapsed by default. Expand it only when checking connection settings, progress variant parameters, context IDs, or recent stream diagnostics.
diff --git a/scripts/a2a/selling_console_web/app.js b/scripts/a2a/selling_console_web/app.js
new file mode 100644
index 00000000..01105ded
--- /dev/null
+++ b/scripts/a2a/selling_console_web/app.js
@@ -0,0 +1,4327 @@
+(function () {
+ const STEP_ORDER = ["intent_parsing", "architecture_planning", "evaluate_candidates", "confirm_and_select", "deploying"];
+ const STEP_LABELS = {
+ intent_parsing: "需求理解",
+ architecture_planning: "架构规划",
+ evaluate_candidates: "方案评估",
+ confirm_and_select: "方案选择",
+ deploying: "确认部署",
+ };
+ const PROGRESS_VARIANT_ORDER = ["a", "b", "d"];
+ const PROGRESS_VARIANT_LABELS = {
+ a: "A 箭头轨道",
+ b: "B 脉冲线路",
+ d: "D 输入框融合",
+ };
+ const DEFAULT_PROGRESS_UI = {
+ variant: "b",
+ activeStepIndex: null,
+ a: {
+ sweepMs: 1800,
+ },
+ b: {
+ xPercent: 28,
+ yPercent: 49,
+ t1: 140,
+ t2: 540,
+ maxAmplitude: 9,
+ pauseTime: 510,
+ },
+ d: {
+ t1: 1800,
+ t2: 300,
+ },
+ };
+ const PROGRESS_PARAM_DEFS = {
+ a: [
+ { key: "sweepMs", label: "扫光周期", min: 800, max: 2800, step: 100, unit: "ms" },
+ ],
+ b: [
+ { key: "xPercent", label: "X", min: 6, max: 38, step: 1, unit: "%" },
+ { key: "yPercent", label: "Y", min: 20, max: 90, step: 1, unit: "%" },
+ { key: "t1", label: "T1", min: 80, max: 700, step: 20, unit: "ms" },
+ { key: "t2", label: "T2", min: 160, max: 1400, step: 20, unit: "ms" },
+ { key: "maxAmplitude", label: "最大振幅", min: 8, max: 22, step: 1, unit: "" },
+ { key: "pauseTime", label: "停顿时间", min: 120, max: 1200, step: 30, unit: "ms" },
+ ],
+ d: [
+ { key: "t1", label: "T1", min: 800, max: 3200, step: 100, unit: "ms" },
+ { key: "t2", label: "T2", min: 0, max: 1200, step: 50, unit: "ms" },
+ ],
+ };
+ const MAX_CANDIDATE_SUB_EVENTS = 96;
+ const CURRENT_STEP_EVENT_TYPES = new Set([
+ "permission_requested",
+ "text_delta",
+ "tool_call",
+ "tool_result",
+ "tool_started",
+ "tool_use",
+ ]);
+ const NORMAL_HANDOFF_TEXT = "部署流程已完成,已进入普通会话。可以继续追问资源、运维或变更需求。";
+ const CANDIDATE_SUBSTEP_LABELS = {
+ template_generating: "模板生成",
+ template_generation: "模板生成",
+ template_validating: "模板校验",
+ template_validation: "模板校验",
+ cost_estimating: "成本估算",
+ cost_estimation: "成本估算",
+ cost_estimate: "成本估算",
+ price_estimating: "价格估算",
+ quality_review: "质量复核",
+ architecture_review: "架构复核",
+ risk_review: "风险复核",
+ resource_planning: "资源规划",
+ requirement_matching: "需求匹配",
+ };
+ const CANDIDATE_STEP_IDS = new Set([
+ "candidate",
+ "candidate_generation",
+ "candidate_selection",
+ "candidate_summary",
+ "cost_estimation",
+ "evaluate_candidate",
+ "evaluate_candidates",
+ "resource_evaluation",
+ ]);
+
+ function createSteps() {
+ return STEP_ORDER.reduce((steps, stepId) => {
+ steps[stepId] = {
+ id: stepId,
+ label: STEP_LABELS[stepId],
+ status: "pending",
+ events: [],
+ };
+ return steps;
+ }, {});
+ }
+
+ function mergeProgressParams(variant, params) {
+ const defaults = DEFAULT_PROGRESS_UI[variant] || {};
+ const source = params && typeof params === "object" ? params : {};
+ return Object.keys(defaults).reduce((result, key) => {
+ const numericValue = Number(source[key]);
+ result[key] = Number.isFinite(numericValue) ? numericValue : defaults[key];
+ return result;
+ }, {});
+ }
+
+ function mergeProgressUi(value) {
+ const source = value && typeof value === "object" ? value : {};
+ const variant = PROGRESS_VARIANT_ORDER.includes(source.variant) ? source.variant : DEFAULT_PROGRESS_UI.variant;
+ const rawActiveStepIndex =
+ source.activeStepIndex === null || source.activeStepIndex === undefined ? null : Number(source.activeStepIndex);
+ return {
+ variant,
+ activeStepIndex:
+ Number.isInteger(rawActiveStepIndex) && rawActiveStepIndex >= 0 && rawActiveStepIndex < STEP_ORDER.length
+ ? rawActiveStepIndex
+ : null,
+ a: mergeProgressParams("a", source.a),
+ b: mergeProgressParams("b", source.b),
+ d: mergeProgressParams("d", source.d),
+ };
+ }
+
+ function createInitialState(defaults = {}) {
+ const stateDefaults = clonePlainData(defaults && typeof defaults === "object" ? defaults : {});
+ return {
+ defaults: stateDefaults,
+ serverUrl: stateDefaults.serverUrl || "",
+ cwd: stateDefaults.cwd || "",
+ contextId: "",
+ pipelineTaskId: "",
+ activeTaskId: "",
+ currentStepId: "",
+ lastSequence: 0,
+ status: "idle",
+ pipelineStarted: Boolean(stateDefaults.pipelineStarted),
+ normalHandoffReady: false,
+ steps: createSteps(),
+ candidates: [],
+ selectedCandidateIndex: null,
+ selectedPendingInputOptionId: stateDefaults.selectedPendingInputOptionId || "",
+ pendingInput: null,
+ permission: null,
+ userMessages: Array.isArray(stateDefaults.userMessages) ? clonePlainData(stateDefaults.userMessages) : [],
+ normalTurns: Array.isArray(stateDefaults.normalTurns) ? clonePlainData(stateDefaults.normalTurns) : [],
+ pendingNormalUserMessageId: stateDefaults.pendingNormalUserMessageId || "",
+ expandedStepDetails: clonePlainData(stateDefaults.expandedStepDetails || {}),
+ expandedCandidateSubpipelines: clonePlainData(stateDefaults.expandedCandidateSubpipelines || {}),
+ expandedNormalProcesses: clonePlainData(stateDefaults.expandedNormalProcesses || {}),
+ progressUi: mergeProgressUi(stateDefaults.progressUi),
+ diagnostics: { requests: [], sse: [], snapshots: [] },
+ };
+ }
+
+ function cloneStep(step) {
+ return {
+ ...step,
+ events: Array.isArray(step.events) ? step.events.map(clonePlainData) : [],
+ };
+ }
+
+ function cloneCandidate(candidate) {
+ return clonePlainData({
+ ...candidate,
+ costItems: Array.isArray(candidate.costItems) ? candidate.costItems : [],
+ subEvents: Array.isArray(candidate.subEvents) ? candidate.subEvents : [],
+ });
+ }
+
+ function clonePendingInput(pendingInput) {
+ if (!pendingInput) {
+ return null;
+ }
+ const nextPendingInput = clonePlainData(pendingInput);
+ return {
+ ...nextPendingInput,
+ prompt: nextPendingInput.prompt || nextPendingInput.question || "",
+ options: Array.isArray(nextPendingInput.options) ? nextPendingInput.options : [],
+ };
+ }
+
+ function cloneDiagnostics(diagnostics) {
+ const source = diagnostics || {};
+ return clonePlainData({
+ requests: Array.isArray(source.requests) ? [...source.requests] : [],
+ sse: Array.isArray(source.sse) ? [...source.sse] : [],
+ snapshots: Array.isArray(source.snapshots) ? [...source.snapshots] : [],
+ });
+ }
+
+ function clonePlainData(value) {
+ if (Array.isArray(value)) {
+ return value.map(clonePlainData);
+ }
+ if (value && typeof value === "object") {
+ return Object.keys(value).reduce((result, key) => {
+ result[key] = clonePlainData(value[key]);
+ return result;
+ }, {});
+ }
+ return value;
+ }
+
+ function cloneState(state) {
+ if (!state) {
+ return createInitialState();
+ }
+ const steps = {};
+ const defaultSteps = createSteps();
+ STEP_ORDER.forEach((stepId) => {
+ steps[stepId] = cloneStep(state.steps && state.steps[stepId] ? state.steps[stepId] : defaultSteps[stepId]);
+ });
+ return {
+ ...state,
+ defaults: clonePlainData(state.defaults || {}),
+ steps,
+ candidates: Array.isArray(state.candidates) ? state.candidates.map(cloneCandidate) : [],
+ selectedPendingInputOptionId: state.selectedPendingInputOptionId || "",
+ pendingInput: clonePendingInput(state.pendingInput),
+ permission: clonePlainData(state.permission),
+ currentStepId: state.currentStepId || "",
+ userMessages: Array.isArray(state.userMessages) ? state.userMessages.map(clonePlainData) : [],
+ normalTurns: Array.isArray(state.normalTurns) ? state.normalTurns.map(clonePlainData) : [],
+ pendingNormalUserMessageId: state.pendingNormalUserMessageId || "",
+ expandedStepDetails: clonePlainData(state.expandedStepDetails || {}),
+ expandedCandidateSubpipelines: clonePlainData(state.expandedCandidateSubpipelines || {}),
+ expandedNormalProcesses: clonePlainData(state.expandedNormalProcesses || {}),
+ pipelineStarted: Boolean(state.pipelineStarted),
+ progressUi: mergeProgressUi(state.progressUi),
+ diagnostics: cloneDiagnostics(state.diagnostics),
+ };
+ }
+
+ function pipelineFromMetadata(metadata) {
+ if (!metadata || typeof metadata !== "object") {
+ return null;
+ }
+ if (metadata.pipeline) {
+ return metadata.pipeline;
+ }
+ const iacCode = metadata.iac_code || metadata.iacCode || metadata["iac-code"];
+ if (iacCode && typeof iacCode === "object") {
+ return iacCode.pipeline || iacCode.pipelineEvent || iacCode.pipelineSnapshot || null;
+ }
+ return null;
+ }
+
+ function valueOf(source, ...keys) {
+ if (!source || typeof source !== "object") {
+ return undefined;
+ }
+ for (const key of keys) {
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
+ return source[key];
+ }
+ }
+ return undefined;
+ }
+
+ function eventTypeOf(source) {
+ return valueOf(source, "eventType", "event_type");
+ }
+
+ function taskIdOf(source) {
+ return valueOf(source, "deliveryTaskId", "delivery_task_id", "taskId", "task_id");
+ }
+
+ function contextIdOf(source) {
+ return valueOf(source, "deliveryContextId", "delivery_context_id", "contextId", "context_id");
+ }
+
+ function sequenceOf(source) {
+ const sequence = valueOf(source, "sequence", "lastSequence", "last_sequence");
+ const numericSequence = Number(sequence);
+ return Number.isFinite(numericSequence) ? numericSequence : null;
+ }
+
+ function pendingInputOf(source) {
+ return valueOf(source, "pendingInput", "pending_input", "input");
+ }
+
+ function normalHandoffOf(source) {
+ return valueOf(source, "normalHandoff", "normal_handoff");
+ }
+
+ function targetModeOf(source) {
+ return valueOf(source, "targetMode", "target_mode");
+ }
+
+ function updateLastSequence(state, sequence) {
+ if (typeof sequence === "number") {
+ state.lastSequence = Math.max(state.lastSequence || 0, sequence);
+ }
+ }
+
+ function extractPipelineEnvelope(payload) {
+ if (!payload || typeof payload !== "object") {
+ return null;
+ }
+ if (Array.isArray(payload)) {
+ for (const item of payload) {
+ const envelope = extractPipelineEnvelope(item);
+ if (envelope) {
+ return envelope;
+ }
+ }
+ return null;
+ }
+
+ const metadataPipeline = pipelineFromMetadata(payload.metadata);
+ if (metadataPipeline) {
+ return metadataPipeline;
+ }
+ if (payload.iac_code && payload.iac_code.pipeline) {
+ return payload.iac_code.pipeline;
+ }
+ if (payload.iacCode && payload.iacCode.pipeline) {
+ return payload.iacCode.pipeline;
+ }
+ if (payload["iac-code"] && payload["iac-code"].pipeline) {
+ return payload["iac-code"].pipeline;
+ }
+ if (payload.pipeline || payload.pipelineEvent || payload.pipelineSnapshot) {
+ return payload.pipeline || payload.pipelineEvent || payload.pipelineSnapshot;
+ }
+ if (eventTypeOf(payload) || taskIdOf(payload) || contextIdOf(payload) || payload.step) {
+ return payload;
+ }
+
+ const wrapperKeys = [
+ "result",
+ "params",
+ "task",
+ "statusUpdate",
+ "status_update",
+ "status",
+ "message",
+ "event",
+ "events",
+ "snapshot",
+ ];
+ for (const key of wrapperKeys) {
+ if (payload[key] && typeof payload[key] === "object") {
+ const envelope = extractPipelineEnvelope(payload[key]);
+ if (envelope) {
+ return envelope;
+ }
+ }
+ }
+ return null;
+ }
+
+ function normalizeStatus(status) {
+ if (status === "input_required") {
+ return "waiting_input";
+ }
+ return status || "";
+ }
+
+ function statusFromEventType(eventType, fallbackStatus) {
+ const statuses = {
+ step_started: "working",
+ step_completed: "completed",
+ step_failed: "failed",
+ input_required: "waiting_input",
+ };
+ return statuses[eventType] || normalizeStatus(fallbackStatus);
+ }
+
+ function normalizeStepId(step) {
+ const rawStepId = typeof step === "string" ? step : step && (step.id || step.name || step.stepId);
+ if (!rawStepId) {
+ return "";
+ }
+ const stepId = String(rawStepId);
+ if (CANDIDATE_STEP_IDS.has(stepId) || stepId.startsWith("candidate_") || stepId.includes("candidate")) {
+ return "evaluate_candidates";
+ }
+ if (STEP_ORDER.includes(stepId)) {
+ return stepId;
+ }
+ return stepId;
+ }
+
+ function normalizeCandidateIndexValue(candidateIndex) {
+ if (candidateIndex === null || candidateIndex === undefined || candidateIndex === "") {
+ return candidateIndex;
+ }
+ const numericIndex = Number(candidateIndex);
+ return Number.isFinite(numericIndex) ? numericIndex : candidateIndex;
+ }
+
+ function candidateFromDisplayItem(item) {
+ if (!item || typeof item !== "object") {
+ return null;
+ }
+ const detail = item.detail && typeof item.detail === "object" ? item.detail : item;
+ const nestedCandidate =
+ item.candidate && typeof item.candidate === "object"
+ ? item.candidate
+ : detail.candidate && typeof detail.candidate === "object"
+ ? detail.candidate
+ : {};
+ const cost =
+ item.cost && typeof item.cost === "object"
+ ? item.cost
+ : detail.cost && typeof detail.cost === "object"
+ ? detail.cost
+ : {};
+ const conclusions =
+ item.conclusions && typeof item.conclusions === "object"
+ ? item.conclusions
+ : detail.conclusions && typeof detail.conclusions === "object"
+ ? detail.conclusions
+ : {};
+ const templateConclusion = conclusions.template && typeof conclusions.template === "object" ? conclusions.template : {};
+ const costConclusion = conclusions.cost && typeof conclusions.cost === "object" ? conclusions.cost : {};
+ const primitiveCost = (value) => (value && typeof value === "object" ? undefined : value);
+ const candidateIndex =
+ item.candidateIndex ??
+ item.candidate_index ??
+ item.optionIndex ??
+ item.option_index ??
+ item.index ??
+ item.id ??
+ (item.candidate && item.candidate.index) ??
+ detail.candidateIndex ??
+ detail.candidate_index ??
+ detail.optionIndex ??
+ detail.option_index ??
+ detail.index ??
+ detail.id ??
+ null;
+ return {
+ name:
+ item.name ||
+ item.candidateName ||
+ item.candidate_name ||
+ detail.candidateName ||
+ detail.candidate_name ||
+ nestedCandidate.candidateName ||
+ nestedCandidate.candidate_name ||
+ detail.name ||
+ nestedCandidate.name ||
+ item.title ||
+ detail.title ||
+ nestedCandidate.title ||
+ item.label ||
+ detail.label ||
+ nestedCandidate.label ||
+ item.template ||
+ detail.template ||
+ "",
+ candidateIndex: normalizeCandidateIndexValue(candidateIndex),
+ summary:
+ item.summary ||
+ item.firstVersionDescription ||
+ item.first_version_description ||
+ item.planDescription ||
+ item.plan_description ||
+ item.pros ||
+ item.topology ||
+ detail.summary ||
+ detail.firstVersionDescription ||
+ detail.first_version_description ||
+ detail.planDescription ||
+ detail.plan_description ||
+ detail.pros ||
+ detail.topology ||
+ templateConclusion.summary ||
+ templateConclusion.description ||
+ nestedCandidate.summary ||
+ nestedCandidate.firstVersionDescription ||
+ nestedCandidate.first_version_description ||
+ item.description ||
+ detail.description ||
+ nestedCandidate.description ||
+ nestedCandidate.topology ||
+ nestedCandidate.pros ||
+ "",
+ template: item.template || detail.template || "",
+ totalMonthlyCost:
+ item.totalMonthlyCost ??
+ item.total_monthly_cost ??
+ item.monthlyCost ??
+ item.monthly_cost ??
+ item.monthlyEstimate ??
+ item.monthly_estimate ??
+ item.roughMonthlyEstimate ??
+ item.rough_monthly_estimate ??
+ item.estimatedMonthlyCost ??
+ item.estimated_monthly_cost ??
+ primitiveCost(item.cost) ??
+ item.price ??
+ detail.totalMonthlyCost ??
+ detail.total_monthly_cost ??
+ detail.monthlyCost ??
+ detail.monthly_cost ??
+ detail.monthlyEstimate ??
+ detail.monthly_estimate ??
+ detail.roughMonthlyEstimate ??
+ detail.rough_monthly_estimate ??
+ detail.estimatedMonthlyCost ??
+ detail.estimated_monthly_cost ??
+ primitiveCost(detail.cost) ??
+ detail.price ??
+ cost.totalMonthlyCost ??
+ cost.total_monthly_cost ??
+ cost.monthlyCost ??
+ cost.monthly_cost ??
+ cost.monthlyEstimate ??
+ cost.monthly_estimate ??
+ costConclusion.totalMonthlyCost ??
+ costConclusion.total_monthly_cost ??
+ costConclusion.monthlyEstimate ??
+ costConclusion.monthly_estimate ??
+ nestedCandidate.totalMonthlyCost ??
+ nestedCandidate.total_monthly_cost ??
+ nestedCandidate.monthlyEstimate ??
+ nestedCandidate.monthly_estimate ??
+ "",
+ outputPath:
+ item.outputPath ||
+ item.output_path ||
+ detail.outputPath ||
+ detail.output_path ||
+ templateConclusion.outputPath ||
+ templateConclusion.output_path ||
+ templateConclusion.filePath ||
+ templateConclusion.file_path ||
+ nestedCandidate.outputPath ||
+ nestedCandidate.output_path ||
+ "",
+ costItems: Array.isArray(item.costItems)
+ ? clonePlainData(item.costItems)
+ : Array.isArray(detail.costItems)
+ ? clonePlainData(detail.costItems)
+ : Array.isArray(cost.costItems)
+ ? clonePlainData(cost.costItems)
+ : Array.isArray(cost.items)
+ ? clonePlainData(cost.items)
+ : Array.isArray(cost.resources)
+ ? clonePlainData(cost.resources)
+ : Array.isArray(costConclusion.costItems)
+ ? clonePlainData(costConclusion.costItems)
+ : Array.isArray(costConclusion.items)
+ ? clonePlainData(costConclusion.items)
+ : Array.isArray(costConclusion.resources)
+ ? clonePlainData(costConclusion.resources)
+ : [],
+ };
+ }
+
+ function candidateIndexFromSource(source) {
+ if (!source || typeof source !== "object") {
+ return null;
+ }
+ const data = source.data && typeof source.data === "object" ? source.data : {};
+ const candidate = source.candidate && typeof source.candidate === "object" ? source.candidate : {};
+ const rawIndex =
+ source.candidateIndex ??
+ source.candidate_index ??
+ source.optionIndex ??
+ source.option_index ??
+ candidate.index ??
+ candidate.id ??
+ candidate.candidateIndex ??
+ candidate.candidate_index ??
+ data.candidateIndex ??
+ data.candidate_index ??
+ data.optionIndex ??
+ data.option_index ??
+ null;
+ const normalizedIndex = normalizeCandidateIndexValue(rawIndex);
+ return normalizedIndex === "" || normalizedIndex === null || normalizedIndex === undefined ? null : normalizedIndex;
+ }
+
+ function candidateSelectionInputKind(source) {
+ if (!source || typeof source !== "object") {
+ return "";
+ }
+ return String(source.kind || source.inputKind || source.input_kind || source.type || "");
+ }
+
+ function hasCandidateSelectionOptions(source) {
+ const kind = candidateSelectionInputKind(source);
+ return (kind === "candidate_selection" || kind === "candidate_select") && Array.isArray(source.options);
+ }
+
+ function isCandidateSubPipelineEvent(envelope, stepId) {
+ const eventType = eventTypeOf(envelope || {});
+ const candidateIndex = candidateIndexFromSource(envelope);
+ if (candidateIndex === null || candidateIndex === undefined) {
+ return false;
+ }
+ if (String(eventType || "").startsWith("candidate_step")) {
+ return true;
+ }
+ if (eventType === "candidate_started" || eventType === "candidate_completed" || eventType === "candidate_failed") {
+ return true;
+ }
+ if (envelope.candidateStep || envelope.candidate_step) {
+ return true;
+ }
+ return (
+ stepId === "evaluate_candidates" &&
+ ["text_delta", "tool_result", "tool_use", "tool_call", "tool_started", "permission_requested"].includes(eventType)
+ );
+ }
+
+ function appendCandidateSubEventInPlace(state, envelope) {
+ const candidateIndex = candidateIndexFromSource(envelope);
+ if (candidateIndex === null || candidateIndex === undefined) {
+ return state;
+ }
+ upsertCandidateInPlace(state, {
+ candidateIndex,
+ name:
+ envelope &&
+ envelope.candidate &&
+ typeof envelope.candidate === "object" &&
+ (envelope.candidate.name || envelope.candidate.title || envelope.candidate.label),
+ });
+ const targetIndex = state.candidates.findIndex(
+ (candidate) => normalizeCandidateIndexValue(candidate.candidateIndex) === candidateIndex
+ );
+ if (targetIndex < 0) {
+ return state;
+ }
+ const target = cloneCandidate(state.candidates[targetIndex]);
+ target.subEvents = Array.isArray(target.subEvents) ? target.subEvents : [];
+ target.subEvents.push(clonePlainData(envelope));
+ target.subEvents = target.subEvents.slice(-MAX_CANDIDATE_SUB_EVENTS);
+ state.candidates[targetIndex] = target;
+ return state;
+ }
+
+ function candidateCollectionsFromSource(source) {
+ if (!source || typeof source !== "object") {
+ return [];
+ }
+ const collections = [];
+ const collectFromObject = (target, options = {}) => {
+ if (!target || typeof target !== "object") {
+ return;
+ }
+ collections.push(
+ target.candidateDetails,
+ target.candidate_details,
+ target.candidates,
+ target.draftCandidates,
+ target.draft_candidates,
+ target.planCandidates,
+ target.plan_candidates,
+ target.candidateOptions,
+ target.candidate_options,
+ target.candidateSummaries,
+ target.candidate_summaries,
+ target.plans,
+ target.proposals
+ );
+ if (options.includeGenericOptions) {
+ collections.push(target.options);
+ }
+ };
+ const display = source.display && typeof source.display === "object" ? source.display : null;
+ if (display) {
+ collectFromObject(display, { includeGenericOptions: true });
+ }
+ collectFromObject(source);
+ if (hasCandidateSelectionOptions(source)) {
+ collections.push(source.options);
+ }
+ const pendingInput = pendingInputOf(source);
+ if (pendingInput && typeof pendingInput === "object" && hasCandidateSelectionOptions(pendingInput)) {
+ collections.push(pendingInput.options);
+ }
+ const conclusion = source.conclusion && typeof source.conclusion === "object" ? source.conclusion : null;
+ if (conclusion) {
+ collectFromObject(conclusion, { includeGenericOptions: true });
+ }
+ const data = source.data && typeof source.data === "object" ? source.data : null;
+ if (data && data !== source) {
+ collections.push(...candidateCollectionsFromSource(data));
+ }
+ return collections.filter(Array.isArray);
+ }
+
+ function numericConclusionItems(conclusion) {
+ if (!conclusion || typeof conclusion !== "object" || Array.isArray(conclusion)) {
+ return [];
+ }
+ return Object.keys(conclusion)
+ .filter((key) => /^\d+$/.test(key) && conclusion[key] && typeof conclusion[key] === "object")
+ .map((key) => ({
+ index: Number(key),
+ candidateIndex: Number(key),
+ ...conclusion[key],
+ }));
+ }
+
+ function upsertCandidatesFromSource(state, source) {
+ candidateCollectionsFromSource(source).forEach((collection) => {
+ collection.forEach((item) => {
+ upsertCandidateInPlace(state, candidateFromDisplayItem(item));
+ });
+ });
+ const upsertNumericConclusionItems = (current) => {
+ const conclusion = current && current.conclusion && typeof current.conclusion === "object" ? current.conclusion : null;
+ numericConclusionItems(conclusion).forEach((item) => {
+ upsertCandidateInPlace(state, candidateFromDisplayItem(item));
+ });
+ const data = current && current.data && typeof current.data === "object" ? current.data : null;
+ if (data && data !== current) {
+ upsertNumericConclusionItems(data);
+ }
+ };
+ upsertNumericConclusionItems(source);
+ return state;
+ }
+
+ function candidateFromEnvelope(envelope) {
+ if (!envelope || typeof envelope !== "object") {
+ return null;
+ }
+ const data = envelope.data && typeof envelope.data === "object" ? envelope.data : {};
+ const conclusion = data.conclusion && typeof data.conclusion === "object" ? data.conclusion : {};
+ const detail =
+ data.detail && typeof data.detail === "object"
+ ? data.detail
+ : data.candidate_detail && typeof data.candidate_detail === "object"
+ ? data.candidate_detail
+ : {};
+ const eventCandidate = envelope.candidate && typeof envelope.candidate === "object" ? envelope.candidate : {};
+ const dataCandidate = data.candidate && typeof data.candidate === "object" ? data.candidate : {};
+ const conclusionCandidate =
+ conclusion.candidate && typeof conclusion.candidate === "object" ? conclusion.candidate : {};
+ const conclusions = data.conclusions && typeof data.conclusions === "object" ? data.conclusions : {};
+ const templateConclusion = conclusions.template && typeof conclusions.template === "object" ? conclusions.template : {};
+ const costConclusion = conclusions.cost && typeof conclusions.cost === "object" ? conclusions.cost : {};
+ const candidateIndex = candidateIndexFromSource(envelope);
+ return candidateFromDisplayItem({
+ ...data,
+ ...conclusion,
+ ...templateConclusion,
+ detail: Object.keys(detail).length ? detail : { ...conclusion, ...templateConclusion },
+ cost: Object.keys(costConclusion).length ? costConclusion : data.cost,
+ candidate: {
+ ...eventCandidate,
+ ...dataCandidate,
+ ...conclusionCandidate,
+ },
+ candidateIndex,
+ });
+ }
+
+ function hasCandidateValue(value) {
+ if (Array.isArray(value)) {
+ return value.length > 0;
+ }
+ return value !== "" && value !== null && value !== undefined;
+ }
+
+ function mergeCandidate(existing, candidate) {
+ const result = cloneCandidate(existing || {});
+ Object.keys(candidate || {}).forEach((key) => {
+ const value = candidate[key];
+ if (hasCandidateValue(value)) {
+ result[key] = clonePlainData(value);
+ } else if (!Object.prototype.hasOwnProperty.call(result, key)) {
+ result[key] = clonePlainData(value);
+ }
+ });
+ return cloneCandidate(result);
+ }
+
+ function upsertCandidateInPlace(state, candidate) {
+ if (!candidate) {
+ return state;
+ }
+ const nextCandidate = cloneCandidate(candidate);
+ nextCandidate.candidateIndex = normalizeCandidateIndexValue(nextCandidate.candidateIndex);
+ const hasNextIndex = nextCandidate.candidateIndex !== null && nextCandidate.candidateIndex !== undefined;
+ const index = state.candidates.findIndex((existing) => {
+ if (hasNextIndex && normalizeCandidateIndexValue(existing.candidateIndex) === nextCandidate.candidateIndex) {
+ return true;
+ }
+ if (existing.name && nextCandidate.name && existing.name === nextCandidate.name) {
+ return true;
+ }
+ return false;
+ });
+ if (index >= 0) {
+ state.candidates[index] = mergeCandidate(state.candidates[index], nextCandidate);
+ } else {
+ state.candidates.push(nextCandidate);
+ }
+ return state;
+ }
+
+ function upsertCandidate(state, candidate) {
+ const nextState = cloneState(state);
+ return upsertCandidateInPlace(nextState, candidate);
+ }
+
+ function pendingInputFromSnapshot(snapshot) {
+ const pendingInput = pendingInputOf(snapshot);
+ if (!pendingInput) {
+ return null;
+ }
+ return pendingInputFromInput(pendingInput);
+ }
+
+ function pendingInputFromInput(input) {
+ if (!input || typeof input !== "object") {
+ return null;
+ }
+ const pendingInput = clonePlainData(input);
+ return {
+ ...pendingInput,
+ prompt: pendingInput.prompt || pendingInput.question || "",
+ options: Array.isArray(pendingInput.options) ? pendingInput.options : [],
+ };
+ }
+
+ function applySnapshot(state, snapshot) {
+ if (!snapshot || typeof snapshot !== "object") {
+ return state;
+ }
+ const taskId = taskIdOf(snapshot);
+ if (taskId) {
+ state.pipelineTaskId = taskId;
+ }
+ const contextId = contextIdOf(snapshot);
+ if (contextId) {
+ state.contextId = contextId;
+ }
+ updateLastSequence(state, sequenceOf(snapshot));
+ if (snapshot.status) {
+ state.status = normalizeStatus(snapshot.status);
+ if (state.status && state.status !== "idle") {
+ state.pipelineStarted = true;
+ }
+ }
+
+ if (Array.isArray(snapshot.steps)) {
+ snapshot.steps.forEach((step) => {
+ const stepId = normalizeStepId(step);
+ if (stepId && state.steps[stepId]) {
+ const status = normalizeStatus(step.status) || state.steps[stepId].status;
+ state.steps[stepId].status = status;
+ if (status && status !== "pending") {
+ state.pipelineStarted = true;
+ }
+ if (status === "working" || status === "waiting_input") {
+ state.currentStepId = stepId;
+ }
+ }
+ });
+ }
+
+ upsertCandidatesFromSource(state, snapshot);
+
+ const pendingInput = pendingInputFromSnapshot(snapshot);
+ if (
+ Object.prototype.hasOwnProperty.call(snapshot, "pendingInput") ||
+ Object.prototype.hasOwnProperty.call(snapshot, "pending_input")
+ ) {
+ state.pendingInput = pendingInputFromInput(pendingInputOf(snapshot));
+ } else if (pendingInput) {
+ state.pendingInput = pendingInput;
+ }
+
+ const normalHandoff = normalHandoffOf(snapshot);
+ if (
+ normalHandoff &&
+ typeof normalHandoff === "object" &&
+ normalHandoff.action === "switch_to_normal" &&
+ targetModeOf(normalHandoff) === "normal"
+ ) {
+ state.normalHandoffReady = true;
+ state.activeTaskId = "";
+ }
+ return state;
+ }
+
+ function currentStepIdFromState(state) {
+ const isActive = (stepId) => {
+ const status = stepStatusClass(normalizeStatus(state && state.steps && state.steps[stepId] && state.steps[stepId].status));
+ return status === "working" || status === "waiting_input";
+ };
+ if (state && state.currentStepId && state.steps && state.steps[state.currentStepId] && isActive(state.currentStepId)) {
+ return state.currentStepId;
+ }
+ const activeStepId = STEP_ORDER.find((stepId) => isActive(stepId));
+ return activeStepId || "";
+ }
+
+ function inferredStepIdForEvent(state, envelope, explicitStepId) {
+ if (explicitStepId) {
+ return explicitStepId;
+ }
+ if (!CURRENT_STEP_EVENT_TYPES.has(eventTypeOf(envelope))) {
+ return "";
+ }
+ return currentStepIdFromState(state);
+ }
+
+ function applyPipelineEnvelope(state, envelope) {
+ if (!envelope) {
+ return state;
+ }
+ const eventType = eventTypeOf(envelope);
+ const taskId = taskIdOf(envelope);
+ if (taskId) {
+ state.pipelineTaskId = taskId;
+ }
+ const contextId = contextIdOf(envelope);
+ if (contextId) {
+ state.contextId = contextId;
+ }
+ updateLastSequence(state, sequenceOf(envelope));
+ if (envelope.status) {
+ state.status = normalizeStatus(envelope.status);
+ }
+
+ const explicitStepId = normalizeStepId(envelope.step);
+ const stepId = inferredStepIdForEvent(state, envelope, explicitStepId);
+ if (eventType === "pipeline_started" || stepId) {
+ state.pipelineStarted = true;
+ }
+ if (stepId && state.steps[stepId]) {
+ state.currentStepId = stepId;
+ state.steps[stepId].status =
+ statusFromEventType(eventType, (envelope.step && envelope.step.status) || envelope.status) ||
+ state.steps[stepId].status;
+ state.steps[stepId].events.push(clonePlainData(envelope));
+ if (eventType === "step_completed" && state.expandedStepDetails) {
+ state.expandedStepDetails[stepId] = false;
+ }
+ }
+ if (isCandidateSubPipelineEvent(envelope, stepId)) {
+ appendCandidateSubEventInPlace(state, envelope);
+ }
+
+ const data = envelope.data || {};
+ if (eventType === "candidate_completed" || eventType === "candidate_failed") {
+ upsertCandidateInPlace(state, candidateFromEnvelope(envelope));
+ const candidateIndex = candidateIndexFromSource(envelope);
+ if (candidateIndex !== null && candidateIndex !== undefined) {
+ state.expandedCandidateSubpipelines = state.expandedCandidateSubpipelines || {};
+ state.expandedCandidateSubpipelines[String(candidateIndex)] = false;
+ }
+ }
+ if (eventType === "candidate_detail_shown") {
+ upsertCandidateInPlace(
+ state,
+ candidateFromDisplayItem({
+ ...data,
+ candidate: envelope.candidate || data.candidate,
+ step: envelope.step || data.step,
+ })
+ );
+ }
+ upsertCandidatesFromSource(state, envelope);
+ if (eventType === "input_required") {
+ state.pendingInput = pendingInputFromInput(pendingInputOf(envelope) || data);
+ }
+ if (eventType === "input_received") {
+ state.pendingInput = null;
+ }
+ if (
+ eventType === "pipeline_handoff_ready" ||
+ (data.action === "switch_to_normal" && targetModeOf(data) === "normal")
+ ) {
+ state.normalHandoffReady = true;
+ state.activeTaskId = "";
+ if (envelope.status) {
+ state.status = normalizeStatus(envelope.status);
+ }
+ }
+ return state;
+ }
+
+ function isSnapshotLike(payload) {
+ if (!payload || typeof payload !== "object") {
+ return false;
+ }
+ if (eventTypeOf(payload)) {
+ return false;
+ }
+ return Boolean(
+ payload.display ||
+ Object.prototype.hasOwnProperty.call(payload, "pendingInput") ||
+ Object.prototype.hasOwnProperty.call(payload, "pending_input") ||
+ normalHandoffOf(payload) ||
+ taskIdOf(payload) ||
+ contextIdOf(payload) ||
+ sequenceOf(payload) !== null ||
+ Array.isArray(payload.steps)
+ );
+ }
+
+ function reducePipelinePayload(state, payload) {
+ const nextState = cloneState(state);
+ const hasEvents = payload && Array.isArray(payload.events);
+ applyPipelineEnvelope(nextState, hasEvents ? null : extractPipelineEnvelope(payload));
+ if (payload && payload.snapshot) {
+ applySnapshot(nextState, payload.snapshot);
+ } else if (isSnapshotLike(payload)) {
+ applySnapshot(nextState, payload);
+ }
+ if (payload && Array.isArray(payload.events)) {
+ payload.events.forEach((event) => {
+ applyPipelineEnvelope(nextState, extractPipelineEnvelope(event));
+ });
+ }
+ applyNormalChatPayload(nextState, payload);
+ return nextState;
+ }
+
+ function a2aSource(payload) {
+ if (!payload || typeof payload !== "object") {
+ return null;
+ }
+ if (Array.isArray(payload)) {
+ for (const item of payload) {
+ const source = a2aSource(item);
+ if (source) {
+ return source;
+ }
+ }
+ return null;
+ }
+ if (payload.status && typeof payload.status === "object") {
+ return payload;
+ }
+ if (payload.metadata && typeof payload.metadata === "object") {
+ return payload;
+ }
+ for (const key of ["result", "params", "event", "task"]) {
+ if (payload[key] && typeof payload[key] === "object") {
+ const source = a2aSource(payload[key]);
+ if (source) {
+ return source;
+ }
+ }
+ }
+ return null;
+ }
+
+ function a2aTaskId(source) {
+ return (
+ taskIdOf(source || {}) ||
+ valueOf(source || {}, "id") ||
+ (source && source.task && typeof source.task === "object" && (taskIdOf(source.task) || source.task.id)) ||
+ ""
+ );
+ }
+
+ function normalizeA2aState(value) {
+ if (!value) {
+ return "";
+ }
+ const normalized = String(value)
+ .trim()
+ .toLowerCase()
+ .replace(/^task_state_/, "")
+ .replace(/-/g, "_");
+ if (normalized === "input_required") {
+ return "completed";
+ }
+ if (normalized === "completed" || normalized === "failed" || normalized === "canceled" || normalized === "working") {
+ return normalized;
+ }
+ return normalized;
+ }
+
+ function partText(part) {
+ if (typeof part === "string") {
+ return part;
+ }
+ if (!part || typeof part !== "object") {
+ return "";
+ }
+ if (typeof part.text === "string") {
+ return part.text;
+ }
+ if (part.root && typeof part.root === "object") {
+ return partText(part.root);
+ }
+ if (part.data && typeof part.data === "object" && typeof part.data.text === "string") {
+ return part.data.text;
+ }
+ return "";
+ }
+
+ function contentBlockText(block) {
+ if (typeof block === "string") {
+ return block;
+ }
+ if (!block || typeof block !== "object") {
+ return "";
+ }
+ const type = String(block.type || block.kind || "").toLowerCase();
+ if (type && type !== "text" && type !== "output_text") {
+ return "";
+ }
+ if (typeof block.text === "string") {
+ return block.text;
+ }
+ if (typeof block.content === "string") {
+ return block.content;
+ }
+ return partText(block);
+ }
+
+ function messageText(message) {
+ if (typeof message === "string") {
+ return message;
+ }
+ if (!message || typeof message !== "object") {
+ return "";
+ }
+ if (typeof message.text === "string") {
+ return message.text;
+ }
+ if (Array.isArray(message.content)) {
+ return message.content.map(contentBlockText).join("");
+ }
+ const parts = Array.isArray(message.parts) ? message.parts : [];
+ return parts.map(partText).join("");
+ }
+
+ function agentHistoryEntryText(source) {
+ const history = Array.isArray(source && source.history)
+ ? source.history
+ : Array.isArray(source && source.task && source.task.history)
+ ? source.task.history
+ : [];
+ for (let index = history.length - 1; index >= 0; index -= 1) {
+ const entry = history[index];
+ const role = String((entry && entry.role) || "")
+ .toLowerCase()
+ .replace(/^role_/, "");
+ if (!["agent", "assistant"].includes(role)) {
+ continue;
+ }
+ const text = messageText(entry);
+ if (text) {
+ return text;
+ }
+ }
+ return "";
+ }
+
+ function normalAnswerFromSource(source, status) {
+ const liveText = messageText((status && status.message) || (source && source.message));
+ if (liveText) {
+ return { text: liveText, replace: false };
+ }
+ const historyText = agentHistoryEntryText(source);
+ return historyText ? { text: historyText, replace: true } : { text: "", replace: false };
+ }
+
+ function mergeNormalAnswer(existing, next, replace) {
+ if (!next) {
+ return existing || "";
+ }
+ if (!replace) {
+ return `${existing || ""}${next}`;
+ }
+ if (!existing) {
+ return next;
+ }
+ if (next.includes(existing) || existing.includes(next)) {
+ return next.length >= existing.length ? next : existing;
+ }
+ return `${existing}${next}`;
+ }
+
+ function iacMetadata(source) {
+ const metadata = source && source.metadata && typeof source.metadata === "object" ? source.metadata : {};
+ const statusMetadata =
+ source && source.status && source.status.metadata && typeof source.status.metadata === "object"
+ ? source.status.metadata
+ : {};
+ return (
+ metadata.iac_code ||
+ metadata.iacCode ||
+ metadata["iac-code"] ||
+ statusMetadata.iac_code ||
+ statusMetadata.iacCode ||
+ statusMetadata["iac-code"] ||
+ null
+ );
+ }
+
+ function compactValueText(value) {
+ if (value === null || value === undefined) {
+ return "";
+ }
+ if (typeof value === "string") {
+ return value;
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ if (typeof value === "object") {
+ return (
+ value.content ||
+ value.text ||
+ value.summary ||
+ value.safeSummary ||
+ value.message ||
+ value.error ||
+ ""
+ );
+ }
+ return "";
+ }
+
+ function normalToolText(tool) {
+ if (!tool || typeof tool !== "object") {
+ return "";
+ }
+ const statusMap = {
+ started: "开始",
+ input_delta: "输入中",
+ input_complete: "输入完成",
+ completed: "完成",
+ failed: "失败",
+ };
+ const name = tool.name || tool.toolName || "工具";
+ const status = statusMap[tool.status] || tool.status || "";
+ const result = compactValueText(tool.result || tool.artifact || tool.input || tool.partialJson);
+ return [name, status, result].filter(Boolean).join(" ");
+ }
+
+ function normalEventsFromMetadata(metadata) {
+ if (!metadata || typeof metadata !== "object") {
+ return [];
+ }
+ const events = [];
+ if (metadata.thinking && typeof metadata.thinking === "object") {
+ const text = compactValueText(metadata.thinking.text || metadata.thinking);
+ if (text) {
+ events.push({ kind: "thinking", label: "思考", text });
+ }
+ }
+ if (metadata.tool && typeof metadata.tool === "object") {
+ const text = normalToolText(metadata.tool);
+ if (text) {
+ events.push({ kind: "tool", label: "工具", text });
+ }
+ }
+ if (metadata.permission && typeof metadata.permission === "object") {
+ const text = metadata.permission.toolName || metadata.permission.tool_name || "权限确认";
+ events.push({ kind: "permission", label: "权限", text });
+ }
+ if (metadata.error && typeof metadata.error === "object") {
+ const text = compactValueText(metadata.error.message || metadata.error.error || metadata.error);
+ if (text) {
+ events.push({ kind: "error", label: "异常", text });
+ }
+ }
+ return events;
+ }
+
+ function lastNormalUserMessageId(state) {
+ const messages = Array.isArray(state && state.userMessages) ? state.userMessages : [];
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ const placement = userMessagePlacement(message);
+ if (placement.position === "after_normal_handoff") {
+ return userMessageKey(message, index);
+ }
+ }
+ return "";
+ }
+
+ function normalTurnForEvent(state, taskId, shouldCreate) {
+ state.normalTurns = Array.isArray(state.normalTurns) ? state.normalTurns : [];
+ const id = taskId || `normal-turn-${state.normalTurns.length + 1}`;
+ let index = state.normalTurns.findIndex((turn) => turn && (turn.taskId === taskId || turn.id === id));
+ if (index < 0) {
+ if (!shouldCreate) {
+ return null;
+ }
+ const afterUserMessageId = state.pendingNormalUserMessageId || lastNormalUserMessageId(state);
+ state.normalTurns.push({
+ id,
+ taskId,
+ afterUserMessageId,
+ status: "working",
+ answer: "",
+ events: [],
+ });
+ state.pendingNormalUserMessageId = "";
+ index = state.normalTurns.length - 1;
+ }
+ state.normalTurns[index].events = Array.isArray(state.normalTurns[index].events) ? state.normalTurns[index].events : [];
+ return state.normalTurns[index];
+ }
+
+ function applyNormalChatPayload(state, payload) {
+ if (!state || !state.normalHandoffReady) {
+ return state;
+ }
+ const pipelineEnvelope = extractPipelineEnvelope(payload);
+ if (pipelineEnvelope && eventTypeOf(pipelineEnvelope)) {
+ return state;
+ }
+ const source = a2aSource(payload);
+ if (!source) {
+ return state;
+ }
+ const status = source.status && typeof source.status === "object" ? source.status : {};
+ const stateValue = normalizeA2aState(status.state || source.state || source.status);
+ const answer = normalAnswerFromSource(source, status);
+ const answerText = answer.text;
+ const events = normalEventsFromMetadata(iacMetadata(source));
+ const taskId = a2aTaskId(source);
+ const shouldCreate = Boolean(answerText || events.length || stateValue === "working");
+ const turn = normalTurnForEvent(state, taskId, shouldCreate);
+ if (!turn) {
+ return state;
+ }
+ if (taskId) {
+ turn.taskId = taskId;
+ }
+ if (answerText) {
+ turn.answer = mergeNormalAnswer(turn.answer, answerText, answer.replace);
+ }
+ events.forEach((event) => {
+ turn.events.push(clonePlainData(event));
+ });
+ turn.events = turn.events.slice(-80);
+ if (stateValue === "working") {
+ turn.status = "working";
+ } else if (stateValue === "failed" || stateValue === "canceled") {
+ turn.status = stateValue;
+ } else if (stateValue) {
+ turn.status = "completed";
+ }
+ return state;
+ }
+
+ function buildStreamPayload(state, prompt) {
+ const source = state && typeof state === "object" ? state : {};
+ return {
+ serverUrl: source.serverUrl || "",
+ cwd: source.cwd || "",
+ contextId: source.contextId || "",
+ taskId: source.normalHandoffReady ? "" : source.activeTaskId || source.pipelineTaskId || "",
+ prompt: prompt || "",
+ };
+ }
+
+ function selectCandidate(state, candidateIndex) {
+ const nextState = state && typeof state === "object" ? state : createInitialState();
+ const numericIndex = Number(candidateIndex);
+ nextState.selectedCandidateIndex = Number.isFinite(numericIndex) ? numericIndex : null;
+ return nextState;
+ }
+
+ function promptForSelectedCandidate(state) {
+ if (!state || state.selectedCandidateIndex === null || state.selectedCandidateIndex === undefined) {
+ return "";
+ }
+ const numericIndex = Number(state.selectedCandidateIndex);
+ if (!Number.isFinite(numericIndex)) {
+ return "";
+ }
+ return `选择方案${numericIndex}`;
+ }
+
+ window.SellingConsoleReducers = {
+ STEP_ORDER,
+ STEP_LABELS,
+ createInitialState,
+ extractPipelineEnvelope,
+ normalizeStepId,
+ upsertCandidate,
+ reducePipelinePayload,
+ candidateFromDisplayItem,
+ pendingInputFromSnapshot,
+ buildStreamPayload,
+ selectCandidate,
+ promptForSelectedCandidate,
+ };
+
+ const STEP_DESCRIPTIONS = {
+ intent_parsing: "识别业务目标、地域、预算与部署约束。",
+ architecture_planning: "拆解网络、计算、存储与安全资源拓扑。",
+ evaluate_candidates: "比较规格、可用区、成本与运维复杂度。",
+ confirm_and_select: "确认推荐方案并准备转入标准部署流程。",
+ deploying: "复核资源清单、交付方式与后续部署动作。",
+ };
+ const CONCLUSION_FIELD_LABELS = {
+ architecture: "架构",
+ budget: "预算",
+ intent: "需求",
+ isInfraIntent: "基础设施需求",
+ is_infra_intent: "基础设施需求",
+ objective: "目标",
+ plan: "方案",
+ reason: "原因",
+ recommendation: "推荐",
+ region: "地域",
+ scenario: "场景",
+ selectedOption: "已选方案",
+ selectedValue: "已选项",
+ summary: "总结",
+ };
+ const STATUS_LABELS = {
+ idle: "等待输入",
+ pending: "未开始",
+ working: "进行中",
+ completed: "已完成",
+ waiting_input: "等待输入",
+ failed: "失败",
+ error: "失败",
+ };
+ const PROGRESS_STATUS_LABELS = {
+ pending: "待开始",
+ working: "思考中",
+ completed: "完成",
+ waiting_input: "待确认",
+ failed: "失败",
+ error: "失败",
+ };
+ const STEP_DETAIL_STATUS_LABELS = {
+ working: "思考中",
+ completed: "思考完成",
+ waiting_input: "等待确认",
+ failed: "执行失败",
+ error: "执行失败",
+ };
+ const STEP_STATUS_CLASSES = new Set(["pending", "working", "completed", "waiting_input", "failed", "error"]);
+
+ const controller = {
+ state: null,
+ bound: false,
+ progressAnimationFrame: null,
+ progressAnimationToken: 0,
+ progressRunTimer: 0,
+ progressWaitTimer: 0,
+ };
+
+ function hasDocument() {
+ return typeof document !== "undefined" && document !== null;
+ }
+
+ function canCreateElements() {
+ return hasDocument() && typeof document.createElement === "function";
+ }
+
+ function query(selector) {
+ if (!hasDocument() || typeof document.querySelector !== "function") {
+ return null;
+ }
+ return document.querySelector(selector);
+ }
+
+ function byId(id) {
+ if (!hasDocument()) {
+ return null;
+ }
+ if (typeof document.getElementById === "function") {
+ return document.getElementById(id);
+ }
+ return query(`#${id}`);
+ }
+
+ function clearElement(element) {
+ if (!element) {
+ return;
+ }
+ if (typeof element.replaceChildren === "function") {
+ element.replaceChildren();
+ return;
+ }
+ while (element.firstChild && typeof element.removeChild === "function") {
+ element.removeChild(element.firstChild);
+ }
+ if (!element.firstChild) {
+ element.textContent = "";
+ }
+ }
+
+ function appendChild(parent, child) {
+ if (parent && child && typeof parent.appendChild === "function") {
+ parent.appendChild(child);
+ }
+ }
+
+ function createElement(tagName, className, text) {
+ if (!canCreateElements()) {
+ return null;
+ }
+ const svgTags = new Set(["svg", "path"]);
+ const element =
+ svgTags.has(tagName) && typeof document.createElementNS === "function"
+ ? document.createElementNS("http://www.w3.org/2000/svg", tagName)
+ : document.createElement(tagName);
+ if (className) {
+ if (typeof element.setAttribute === "function") {
+ element.setAttribute("class", className);
+ } else {
+ element.className = className;
+ }
+ }
+ if (text !== undefined && text !== null) {
+ element.textContent = String(text);
+ }
+ return element;
+ }
+
+ function addClassName(element, className) {
+ if (!element || !className) {
+ return element;
+ }
+ const current =
+ (typeof element.getAttribute === "function" && element.getAttribute("class")) || element.className || "";
+ const classes = new Set(String(current || "").split(/\s+/).filter(Boolean));
+ String(className || "")
+ .split(/\s+/)
+ .filter(Boolean)
+ .forEach((item) => classes.add(item));
+ const nextClassName = Array.from(classes).join(" ");
+ if (typeof element.setAttribute === "function") {
+ element.setAttribute("class", nextClassName);
+ } else {
+ element.className = nextClassName;
+ }
+ return element;
+ }
+
+ function markMarkdownNode(element, kind) {
+ if (element && kind) {
+ element.setAttribute("data-markdown-node", kind);
+ }
+ return element;
+ }
+
+ function safeMarkdownUrl(value) {
+ const url = String(value || "").trim();
+ if (/^(https?:|mailto:)/i.test(url)) {
+ return url;
+ }
+ return "";
+ }
+
+ function appendInlineMarkdown(parent, text) {
+ if (!parent) {
+ return;
+ }
+ const source = String(text || "");
+ const tokenPattern = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
+ let cursor = 0;
+ source.replace(tokenPattern, (match, _token, offset) => {
+ if (offset > cursor) {
+ appendChild(parent, createElement("span", "", source.slice(cursor, offset)));
+ }
+ if (match.startsWith("**")) {
+ appendChild(parent, markMarkdownNode(createElement("strong", "", match.slice(2, -2)), "strong"));
+ } else if (match.startsWith("`")) {
+ appendChild(parent, markMarkdownNode(createElement("code", "", match.slice(1, -1)), "code"));
+ } else {
+ const linkMatch = match.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
+ const link = createElement("a", "", linkMatch ? linkMatch[1] : match);
+ const href = linkMatch ? safeMarkdownUrl(linkMatch[2]) : "";
+ if (link && href) {
+ link.setAttribute("href", href);
+ link.setAttribute("target", "_blank");
+ link.setAttribute("rel", "noreferrer");
+ }
+ appendChild(parent, markMarkdownNode(link, "a"));
+ }
+ cursor = offset + match.length;
+ return match;
+ });
+ if (cursor < source.length) {
+ appendChild(parent, createElement("span", "", source.slice(cursor)));
+ }
+ }
+
+ function markdownLines(value) {
+ return String(value || "")
+ .replace(/\r\n?/g, "\n")
+ .replace(/([^\n])\s+(\d+[.)]\s+)/g, "$1\n$2")
+ .split("\n");
+ }
+
+ function renderMarkdownText(value, className) {
+ const container = createElement("div", className || "markdown-text");
+ if (container) {
+ container.setAttribute("data-markdown-rendered", "true");
+ }
+ const lines = markdownLines(value);
+ let paragraph = [];
+ const flushParagraph = () => {
+ if (paragraph.length === 0) {
+ return;
+ }
+ const node = createElement("p");
+ appendInlineMarkdown(node, paragraph.join(" ").trim());
+ appendChild(container, node);
+ paragraph = [];
+ };
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index];
+ const trimmed = line.trim();
+ if (!trimmed) {
+ flushParagraph();
+ continue;
+ }
+ if (/^[-*]\s+/.test(trimmed)) {
+ flushParagraph();
+ const list = createElement("ul");
+ while (index < lines.length && /^[-*]\s+/.test(lines[index].trim())) {
+ const item = markMarkdownNode(createElement("li"), "li");
+ appendInlineMarkdown(item, lines[index].trim().replace(/^[-*]\s+/, ""));
+ appendChild(list, item);
+ index += 1;
+ }
+ index -= 1;
+ appendChild(container, list);
+ continue;
+ }
+ if (/^\d+[.)]\s+/.test(trimmed)) {
+ flushParagraph();
+ const list = markMarkdownNode(createElement("ol"), "ol");
+ while (index < lines.length && /^\d+[.)]\s+/.test(lines[index].trim())) {
+ const item = markMarkdownNode(createElement("li"), "li");
+ appendInlineMarkdown(item, lines[index].trim().replace(/^\d+[.)]\s+/, ""));
+ appendChild(list, item);
+ index += 1;
+ }
+ index -= 1;
+ appendChild(container, list);
+ continue;
+ }
+ paragraph.push(trimmed);
+ }
+ flushParagraph();
+ if (container && container.children.length === 0) {
+ appendChild(container, createElement("p", "", ""));
+ }
+ return container;
+ }
+
+ function statusLabel(status) {
+ return STATUS_LABELS[status] || status || "等待输入";
+ }
+
+ function stepStatusClass(status) {
+ return STEP_STATUS_CLASSES.has(status) ? status : "pending";
+ }
+
+ function progressStatusLabel(status) {
+ return PROGRESS_STATUS_LABELS[status] || statusLabel(status);
+ }
+
+ function stepDetailStatusLabel(status) {
+ return STEP_DETAIL_STATUS_LABELS[status] || statusLabel(status);
+ }
+
+ function stepStateIcon(status) {
+ const icons = {
+ completed: "✓",
+ error: "!",
+ failed: "!",
+ waiting_input: "?",
+ working: "…",
+ };
+ return icons[status] || "";
+ }
+
+ function stepIsVisible(step) {
+ const status = stepStatusClass(normalizeStatus(step && step.status) || "pending");
+ return status !== "pending" || (Array.isArray(step && step.events) && step.events.length > 0);
+ }
+
+ function stepIsOpen(status) {
+ return status === "working" || status === "waiting_input";
+ }
+
+ function eventData(event) {
+ return event && event.data && typeof event.data === "object" ? event.data : {};
+ }
+
+ function firstTextValue(source, keys) {
+ if (!source || typeof source !== "object") {
+ return "";
+ }
+ for (const key of keys) {
+ const value = source[key];
+ if (value === 0 || value) {
+ return String(value);
+ }
+ }
+ return "";
+ }
+
+ function friendlyFieldLabel(key) {
+ return CONCLUSION_FIELD_LABELS[key] || key.replace(/_/g, " ");
+ }
+
+ function friendlyValue(value) {
+ if (value === true) {
+ return "是";
+ }
+ if (value === false) {
+ return "否";
+ }
+ if (Array.isArray(value)) {
+ return value
+ .map((item) => {
+ if (item && typeof item === "object") {
+ return firstTextValue(item, ["title", "name", "label", "summary", "description"]);
+ }
+ return item === 0 || item ? String(item) : "";
+ })
+ .filter(Boolean)
+ .slice(0, 3)
+ .join("、");
+ }
+ if (value && typeof value === "object") {
+ return conclusionText(value);
+ }
+ return value === 0 || value ? String(value) : "";
+ }
+
+ function optionsConclusionText(options) {
+ if (!Array.isArray(options) || options.length === 0) {
+ return "";
+ }
+ const names = options
+ .map((option) => {
+ if (option && typeof option === "object") {
+ return firstTextValue(option, ["title", "name", "label", "candidateName"]);
+ }
+ return option === 0 || option ? String(option) : "";
+ })
+ .filter(Boolean)
+ .slice(0, 2);
+ return names.length > 0 ? `已生成 ${options.length} 个方案:${names.join("、")}` : `已生成 ${options.length} 个方案`;
+ }
+
+ function conclusionText(conclusion) {
+ if (conclusion === 0 || conclusion) {
+ if (typeof conclusion !== "object") {
+ return String(conclusion);
+ }
+ } else {
+ return "";
+ }
+ const direct = firstTextValue(conclusion, [
+ "summary",
+ "title",
+ "description",
+ "text",
+ "result",
+ "decision",
+ "recommendation",
+ "selectedOption",
+ "selectedValue",
+ ]);
+ if (direct) {
+ return direct;
+ }
+ const optionsText = optionsConclusionText(conclusion.options || conclusion.candidates || conclusion.candidateDetails);
+ if (optionsText) {
+ return optionsText;
+ }
+ const numericItems = numericConclusionItems(conclusion);
+ if (numericItems.length > 0) {
+ return `已完成 ${numericItems.length} 个方案评估`;
+ }
+ return Object.keys(conclusion)
+ .filter((key) => !["options", "candidates", "candidateDetails"].includes(key))
+ .map((key) => {
+ const value = friendlyValue(conclusion[key]);
+ return value ? `${friendlyFieldLabel(key)}:${value}` : "";
+ })
+ .filter(Boolean)
+ .join(",");
+ }
+
+ function conclusionOptionItems(conclusion) {
+ if (!conclusion || typeof conclusion !== "object") {
+ return [];
+ }
+ const options = conclusion.options || conclusion.candidates || conclusion.candidateDetails;
+ if (Array.isArray(options)) {
+ return options;
+ }
+ return numericConclusionItems(conclusion);
+ }
+
+ function conclusionFieldEntries(conclusion) {
+ if (!conclusion || typeof conclusion !== "object" || Array.isArray(conclusion)) {
+ return [];
+ }
+ if (
+ firstTextValue(conclusion, [
+ "summary",
+ "title",
+ "description",
+ "text",
+ "result",
+ "decision",
+ "recommendation",
+ "selectedOption",
+ "selectedValue",
+ ])
+ ) {
+ return [];
+ }
+ if (optionsConclusionText(conclusion.options || conclusion.candidates || conclusion.candidateDetails)) {
+ return [];
+ }
+ return Object.keys(conclusion)
+ .filter((key) => !["options", "candidates", "candidateDetails"].includes(key))
+ .map((key) => {
+ const value = friendlyValue(conclusion[key]);
+ return value ? { key, label: friendlyFieldLabel(key), value } : null;
+ })
+ .filter(Boolean);
+ }
+
+ function latestStepCompletion(step) {
+ const events = Array.isArray(step && step.events) ? step.events : [];
+ for (let index = events.length - 1; index >= 0; index -= 1) {
+ const event = events[index];
+ const data = eventData(event);
+ const conclusion = data.conclusion || event.conclusion;
+ const text = conclusionText(conclusion) || firstTextValue(data, ["summary", "statusMessage", "text", "errorSummary"]);
+ if (conclusion || text) {
+ return { conclusion, text };
+ }
+ }
+ return { conclusion: null, text: "已完成本步骤。" };
+ }
+
+ function completionTextForStep(step) {
+ return latestStepCompletion(step).text || "已完成本步骤。";
+ }
+
+ function eventText(event) {
+ const data = eventData(event);
+ const eventType = eventTypeOf(event || {});
+ const text =
+ firstTextValue(data, ["summary", "text", "statusMessage", "question", "prompt", "candidateName", "errorSummary"]) ||
+ conclusionText(data.conclusion || event.conclusion);
+ if (text) {
+ return text;
+ }
+ if (eventType === "step_started") {
+ return "开始思考";
+ }
+ if (eventType === "input_required") {
+ return "等待您确认或补充信息";
+ }
+ if (eventType === "candidate_detail_shown") {
+ return "生成候选方案详情";
+ }
+ if (eventType === "permission_requested") {
+ return "等待权限确认";
+ }
+ return eventType || "收到新事件";
+ }
+
+ function compactText(value, maxLength = 180) {
+ if (value === "" || value === null || value === undefined) {
+ return "";
+ }
+ const text = String(value).replace(/\s+/g, " ").trim();
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
+ }
+
+ function summarizeValue(value, maxLength = 180) {
+ if (value === "" || value === null || value === undefined) {
+ return "";
+ }
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
+ return compactText(value, maxLength);
+ }
+ try {
+ return compactText(JSON.stringify(value), maxLength);
+ } catch (_error) {
+ return compactText(value, maxLength);
+ }
+ }
+
+ function toolNameFromEvent(event) {
+ const data = eventData(event);
+ return data.toolName || data.tool_name || data.name || (data.tool && data.tool.name) || "";
+ }
+
+ function objectHasKeys(value) {
+ return Boolean(value && typeof value === "object" && Object.keys(value).length > 0);
+ }
+
+ function toolSummaryFromEvent(event) {
+ const data = eventData(event);
+ const result = data.result && typeof data.result === "object" ? data.result : {};
+ const directSummary =
+ firstTextValue(data, ["safeSummary", "safe_summary", "summary", "text", "statusMessage", "message"]) ||
+ firstTextValue(result, ["safeSummary", "safe_summary", "summary", "message", "content", "text"]);
+ if (directSummary) {
+ return directSummary;
+ }
+ const stackId = data.stackId || data.stack_id || result.stackId || result.stack_id;
+ const stackStatus = data.stackStatus || data.stack_status || result.stackStatus || result.stack_status;
+ const resourceId = data.resourceId || data.resource_id || result.resourceId || result.resource_id;
+ const resourceName = data.resourceName || data.resource_name || result.resourceName || result.resource_name;
+ const status = data.statusMessage || data.statusText || data.status || result.status || "";
+ const parts = [stackId, stackStatus, resourceName, resourceId, status]
+ .map((part) => compactText(part, 80))
+ .filter(Boolean);
+ if (parts.length > 0) {
+ return parts.join(" · ");
+ }
+ if (objectHasKeys(result)) {
+ return summarizeValue(result, 120);
+ }
+ return data.action || "";
+ }
+
+ function stepEventKind(event) {
+ const data = eventData(event);
+ const eventType = eventTypeOf(event || {});
+ const type = data.type || eventType || "";
+ if (type === "tool_result" || eventType === "tool_result") {
+ return "tool_result";
+ }
+ if (type === "tool_use" || eventType === "tool_use" || eventType === "tool_call" || eventType === "tool_started") {
+ return "tool_use";
+ }
+ if (eventType === "input_required") {
+ return "input_required";
+ }
+ if (eventType === "candidate_detail_shown") {
+ return "candidate_detail";
+ }
+ if (eventType === "permission_requested") {
+ return "permission";
+ }
+ if (eventType === "text_delta") {
+ return "text_delta";
+ }
+ return eventType || "event";
+ }
+
+ function textDeltaText(event) {
+ const data = eventData(event);
+ return firstTextValue(data, ["text", "delta", "content", "summary"]);
+ }
+
+ function textDeltaMergeKey(event) {
+ const candidateIndex = candidateIndexFromSource(event);
+ const subStep = candidateSubStepOf(event);
+ const subStepId = subStep.id || subStep.stepId || subStep.name || subStep.label || "";
+ return `${candidateIndex === null || candidateIndex === undefined ? "" : candidateIndex}|${subStepId}`;
+ }
+
+ function compactDisplayEvents(events) {
+ return (Array.isArray(events) ? events : []).reduce((result, event) => {
+ const kind = stepEventKind(event);
+ if (kind !== "text_delta") {
+ result.push(clonePlainData(event));
+ return result;
+ }
+ const fragment = textDeltaText(event);
+ const previous = result[result.length - 1];
+ if (previous && stepEventKind(previous) === "text_delta" && textDeltaMergeKey(previous) === textDeltaMergeKey(event)) {
+ previous.data = previous.data && typeof previous.data === "object" ? previous.data : {};
+ previous.data.text = `${textDeltaText(previous)}${fragment}`;
+ } else {
+ const nextEvent = clonePlainData(event);
+ nextEvent.data = nextEvent.data && typeof nextEvent.data === "object" ? nextEvent.data : {};
+ nextEvent.data.text = fragment;
+ result.push(nextEvent);
+ }
+ return result;
+ }, []);
+ }
+
+ function stepEventLabel(kind) {
+ const labels = {
+ candidate_detail: "方案详情",
+ input_required: "等待输入",
+ permission: "权限确认",
+ step_started: "步骤开始",
+ text_delta: "思考片段",
+ tool_result: "工具结果",
+ tool_use: "工具调用",
+ };
+ return labels[kind] || kind.replace(/_/g, " ");
+ }
+
+ function eventTitle(event) {
+ const data = eventData(event);
+ const kind = stepEventKind(event);
+ if (kind === "tool_result" || kind === "tool_use") {
+ return toolNameFromEvent(event) || "工具";
+ }
+ if (kind === "input_required") {
+ return firstTextValue(data, ["question", "prompt", "summary"]) || "等待您确认或补充信息";
+ }
+ if (kind === "candidate_detail") {
+ const detail = data.detail && typeof data.detail === "object" ? data.detail : data;
+ return firstTextValue(detail, ["candidateName", "name", "title"]) || "生成候选方案详情";
+ }
+ return eventText(event);
+ }
+
+ function eventMetaEntries(event) {
+ const data = eventData(event);
+ const kind = stepEventKind(event);
+ if (kind === "tool_result" || kind === "tool_use") {
+ return [
+ ["摘要", toolSummaryFromEvent(event)],
+ ["地域", data.regionId || data.region_id],
+ ];
+ }
+ if (kind === "input_required") {
+ return [["类型", data.kind], ["选项", Array.isArray(data.options) ? `${data.options.length} 个` : ""]];
+ }
+ if (kind === "permission") {
+ return [["工具", data.toolName || data.tool_name], ["原因", data.reason || data.safeSummary]];
+ }
+ return [];
+ }
+
+ function appendKeyValueList(parent, entries, className) {
+ const filteredEntries = entries
+ .map(([label, value]) => [label, summarizeValue(value)])
+ .filter(([_label, value]) => value);
+ if (filteredEntries.length === 0) {
+ return;
+ }
+ const list = createElement("dl", className || "key-value-list");
+ filteredEntries.forEach(([label, value]) => {
+ const row = createElement("div");
+ appendChild(row, createElement("dt", "", `${label}:`));
+ appendChild(row, createElement("dd", "", value));
+ appendChild(list, row);
+ });
+ appendChild(parent, list);
+ }
+
+ function renderStepEvent(event) {
+ const kind = stepEventKind(event);
+ const item = createElement("li", `step-event-card ${kind}`);
+ if (item) {
+ item.setAttribute("data-step-event-kind", kind);
+ }
+ appendChild(item, createElement("span", "step-event-label", stepEventLabel(kind)));
+ appendChild(item, createElement("p", "step-event-title", eventTitle(event)));
+ appendKeyValueList(item, eventMetaEntries(event), "step-event-meta");
+ return item;
+ }
+
+ function renderStepProcess(detail, step) {
+ const events = compactDisplayEvents(Array.isArray(step && step.events) ? step.events : []);
+ if (events.length === 0) {
+ return;
+ }
+ const process = createElement("details", "step-process");
+ if (process) {
+ process.setAttribute("data-step-process", step.id || "");
+ }
+ const head = createElement("summary", "step-process-head");
+ appendChild(head, createElement("strong", "", "思考过程"));
+ appendChild(head, createElement("span", "", `${events.length} 条事件`));
+ appendChild(process, head);
+ const eventList = createElement("ul", "step-event-list step-process-events");
+ events.forEach((event) => {
+ const item = renderStepEvent(event);
+ if (item) {
+ item.setAttribute("data-step-process-event", stepEventKind(event));
+ }
+ appendChild(eventList, item);
+ });
+ appendChild(process, eventList);
+ appendChild(detail, process);
+ }
+
+ function renderStepResult(detail, step) {
+ const completion = latestStepCompletion(step);
+ const options = conclusionOptionItems(completion.conclusion);
+ if (options.length > 0) {
+ const list = createElement("div", "step-result-options");
+ options.forEach((option, index) => {
+ const candidate = candidateFromDisplayItem(option);
+ const candidateIndex = candidateIndexOf(candidate, index);
+ const item = createElement("article", "step-result-option");
+ if (item) {
+ item.setAttribute("data-step-result-option", String(candidateIndex));
+ }
+ appendChild(item, createElement("strong", "", candidate.name || `方案 ${candidateIndex}`));
+ if (candidate.summary) {
+ appendChild(item, createElement("span", "", candidate.summary));
+ }
+ if (candidate.template && candidate.template !== candidate.summary && candidate.template !== candidate.name) {
+ appendChild(item, createElement("span", "", candidate.template));
+ }
+ if (candidate.totalMonthlyCost !== "" && candidate.totalMonthlyCost !== null && candidate.totalMonthlyCost !== undefined) {
+ appendChild(item, createElement("span", "price", candidate.totalMonthlyCost));
+ }
+ if (candidate.outputPath) {
+ appendChild(item, createElement("span", "template-path", `模板:${candidate.outputPath}`));
+ }
+ appendChild(list, item);
+ });
+ appendChild(detail, list);
+ return;
+ }
+ const entries = conclusionFieldEntries(completion.conclusion);
+ if (entries.length > 0) {
+ const list = createElement("dl", "step-result-list");
+ entries.forEach((entry) => {
+ const row = createElement("div");
+ if (row) {
+ row.setAttribute("data-step-result-field", entry.key);
+ }
+ appendChild(row, createElement("dt", "", `${entry.label}:`));
+ appendChild(row, createElement("dd", "", entry.value));
+ appendChild(list, row);
+ });
+ appendChild(detail, list);
+ return;
+ }
+ appendChild(detail, createElement("p", "step-result", completion.text || "已完成本步骤。"));
+ }
+
+ function candidateResultSummary(candidate) {
+ return (
+ (candidate && (candidate.summary || candidate.template || candidate.description || candidate.pros)) ||
+ "方案摘要已生成,可在右侧查看完整方案。"
+ );
+ }
+
+ function isTemplateLikeText(value) {
+ const text = String(value || "");
+ if (!text) {
+ return false;
+ }
+ return (
+ /ROSTemplateFormatVersion|ALIYUN::|Resources:\s|Parameters:\s|Metadata:\s/.test(text) ||
+ (text.length > 240 && /Type:\s|Properties:\s|Description:\s/.test(text))
+ );
+ }
+
+ function candidateResultSummaryDisplay(candidate) {
+ const rawSummary = candidateResultSummary(candidate);
+ const templateText = candidate && isTemplateLikeText(candidate.template) ? String(candidate.template) : "";
+ if (isTemplateLikeText(rawSummary)) {
+ return {
+ text: "模板内容已生成,悬浮查看完整模板。",
+ template: String(rawSummary),
+ };
+ }
+ const compactSummary = compactText(rawSummary, 140);
+ return {
+ text: compactSummary,
+ template: templateText,
+ title: compactSummary !== String(rawSummary || "") ? String(rawSummary || "") : "",
+ };
+ }
+
+ function attachTemplatePopover(host, templateText) {
+ if (!host || !templateText) {
+ return host;
+ }
+ addClassName(host, "template-popover-host");
+ const popover = createElement("div", "template-popover");
+ if (popover) {
+ popover.setAttribute("data-template-popover", "true");
+ popover.setAttribute("role", "tooltip");
+ popover.setAttribute("tabindex", "0");
+ }
+ appendChild(popover, createElement("div", "template-popover-title", "模板内容"));
+ appendChild(popover, createElement("pre", "", templateText));
+ appendChild(host, popover);
+ return host;
+ }
+
+ function renderCandidateProcess(process, candidate, candidateIndex) {
+ const events = compactDisplayEvents(Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : []);
+ const renderableEvents = candidateRenderableSubEvents(events);
+ if (renderableEvents.length === 0) {
+ return;
+ }
+ const details = createElement("details", "step-candidate-result-process");
+ if (details) {
+ details.setAttribute("data-step-candidate-result-process", String(candidateIndex));
+ details.open = false;
+ }
+ const head = createElement("summary", "step-process-head");
+ appendChild(head, createElement("strong", "", "思考过程"));
+ const groups = groupCandidateSubEvents(renderableEvents, { forceComplete: candidateEvaluationIsComplete() });
+ appendChild(head, createElement("span", "", `${groups.length} 个子步骤`));
+ appendChild(details, head);
+ const body = createElement("div", "step-candidate-result-process-body");
+ const substeps = createElement("div", "candidate-substeps");
+ groups.forEach((group) => {
+ appendChild(substeps, renderCandidateSubstepGroup(group));
+ });
+ appendChild(body, substeps);
+ appendChild(details, body);
+ appendChild(process, details);
+ }
+
+ function renderStepCandidateResults(detail, step) {
+ if (!step || step.id !== "evaluate_candidates") {
+ return false;
+ }
+ const state = ensureState();
+ const candidates = Array.isArray(state.candidates) ? state.candidates : [];
+ if (candidates.length === 0) {
+ return false;
+ }
+ const list = createElement("div", "step-candidate-result-list");
+ candidates.forEach((candidate, index) => {
+ const candidateIndex = candidateIndexOf(candidate, index);
+ const item = createElement("article", "step-candidate-result");
+ if (item) {
+ item.setAttribute("data-step-candidate-result", String(candidateIndex));
+ }
+ const summary = candidateResultSummaryDisplay(candidate);
+ const head = createElement("div", "step-candidate-result-head");
+ appendChild(head, createElement("strong", "", `方案 ${candidateIndex}`));
+ appendChild(head, createElement("span", "", candidate.name || `方案 ${candidateIndex}`));
+ appendChild(item, head);
+ appendChild(item, createElement("span", "step-candidate-result-label", "评估结论"));
+ const summaryNode = createElement("p", "step-candidate-result-summary", summary.text);
+ if (summaryNode) {
+ summaryNode.setAttribute("data-step-candidate-result-summary", String(candidateIndex));
+ }
+ appendChild(item, summaryNode);
+ if (candidate.template && candidate.template !== candidate.summary && !isTemplateLikeText(candidate.template)) {
+ appendChild(item, createElement("span", "step-candidate-result-template", candidate.template));
+ }
+ if (candidate.totalMonthlyCost !== "" && candidate.totalMonthlyCost !== null && candidate.totalMonthlyCost !== undefined) {
+ appendChild(item, createElement("span", "step-candidate-result-price", candidate.totalMonthlyCost));
+ }
+ renderCandidateProcess(item, candidate, candidateIndex);
+ attachTemplatePopover(item, summary.template);
+ appendChild(list, item);
+ });
+ appendChild(detail, list);
+ return true;
+ }
+
+ function candidateProgressText(event) {
+ const kind = candidateSubEventKind(event);
+ if (kind === "tool_result" || kind === "tool_use") {
+ return { label: candidateSubEventLabel(kind), title: eventTitle(event) };
+ }
+ if (String(kind || "").startsWith("candidate_step")) {
+ return { label: candidateSubStepLabel(event), title: eventTitle(event) };
+ }
+ return { label: stepEventLabel(kind), title: eventTitle(event) };
+ }
+
+ function renderStepCandidateProgress(detail) {
+ const state = ensureState();
+ const candidates = Array.isArray(state.candidates) ? state.candidates : [];
+ const rows = candidates
+ .map((candidate, index) => {
+ const events = compactDisplayEvents(Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : []);
+ return { candidate, candidateIndex: candidateIndexOf(candidate, index), event: events[events.length - 1] };
+ })
+ .filter((row) => row.event);
+ if (rows.length === 0) {
+ return false;
+ }
+ const list = createElement("div", "step-candidate-progress-list");
+ rows.forEach((row) => {
+ const item = createElement("article", "step-candidate-progress");
+ const progress = candidateProgressText(row.event);
+ const head = createElement("div", "step-candidate-progress-head");
+ if (item) {
+ item.setAttribute("data-step-candidate-progress", String(row.candidateIndex));
+ }
+ if (head) {
+ head.setAttribute("data-step-candidate-progress-head", String(row.candidateIndex));
+ }
+ appendChild(head, createElement("strong", "", `方案 ${row.candidateIndex}`));
+ appendChild(head, createElement("span", "", row.candidate.name || `方案 ${row.candidateIndex}`));
+ appendChild(item, head);
+ appendChild(item, createElement("span", "", progress.label));
+ appendChild(item, createElement("p", "", progress.title));
+ appendChild(list, item);
+ });
+ appendChild(detail, list);
+ return true;
+ }
+
+ function stepCanToggle(status) {
+ return status === "completed";
+ }
+
+ function stepDetailsExpanded(stepId, status) {
+ const state = ensureState();
+ return stepCanToggle(status) && Boolean(state.expandedStepDetails && state.expandedStepDetails[stepId]);
+ }
+
+ function toggleStepDetails(stepId) {
+ const state = ensureState();
+ state.expandedStepDetails = state.expandedStepDetails || {};
+ state.expandedStepDetails[stepId] = !Boolean(state.expandedStepDetails[stepId]);
+ renderAll();
+ }
+
+ function renderStepDetails(card, step, status, expanded) {
+ if (stepCanToggle(status) && !expanded) {
+ return;
+ }
+ const detail = createElement("div", "step-detail");
+
+ if (stepIsOpen(status)) {
+ const badge = createElement("span", "step-status", stepDetailStatusLabel(status));
+ appendChild(detail, badge);
+ if (status === "waiting_input") {
+ const state = ensureState();
+ renderPendingInputCard(detail, state);
+ renderStepProcess(detail, step);
+ appendChild(card, detail);
+ return;
+ }
+ const handledByCandidateSummary = step.id === "evaluate_candidates" && renderStepCandidateProgress(detail);
+ if (!handledByCandidateSummary) {
+ const events = compactDisplayEvents(Array.isArray(step.events) ? step.events : []);
+ const eventList = createElement("ul", "step-event-list");
+ if (eventList) {
+ eventList.setAttribute("data-step-event-list", step.id || "");
+ }
+ events.forEach((event) => {
+ appendChild(eventList, renderStepEvent(event));
+ });
+ if (events.length === 0) {
+ appendChild(eventList, createElement("li", "step-event-card", STEP_DESCRIPTIONS[step.id] || "正在处理当前步骤"));
+ }
+ appendChild(detail, eventList);
+ scrollElementToBottom(eventList);
+ }
+ } else if (status === "completed" && expanded) {
+ if (!renderStepCandidateResults(detail, step)) {
+ renderStepResult(detail, step);
+ renderStepProcess(detail, step);
+ }
+ } else if (status === "failed" || status === "error") {
+ const badge = createElement("span", "step-status", stepDetailStatusLabel(status));
+ appendChild(detail, badge);
+ renderStepResult(detail, step);
+ renderStepProcess(detail, step);
+ }
+ appendChild(card, detail);
+ }
+
+ function candidateChoiceText(candidate, fallbackIndex) {
+ const candidateIndex = candidateIndexOf(candidate, fallbackIndex);
+ const name = candidate && candidate.name ? candidate.name : `方案 ${candidateIndex}`;
+ const summary = candidate && candidate.summary ? candidate.summary : "";
+ const price = presentValue(candidate && candidate.totalMonthlyCost, "");
+ return `${name}${summary}${price}`;
+ }
+
+ function pendingInputIsCandidateSelection(pendingInput) {
+ if (!pendingInput || typeof pendingInput !== "object") {
+ return false;
+ }
+ const kind = pendingInput.kind || "";
+ return kind === "candidate_selection" || kind === "candidate_select";
+ }
+
+ function candidatesForPendingSelection(state) {
+ const pendingInput = state && state.pendingInput;
+ if (!pendingInputIsCandidateSelection(pendingInput)) {
+ return [];
+ }
+ const candidates = Array.isArray(state.candidates) ? state.candidates : [];
+ if (candidates.length > 0) {
+ return candidates;
+ }
+ return Array.isArray(pendingInput.options) ? pendingInput.options.map(candidateFromDisplayItem).filter(Boolean) : [];
+ }
+
+ function renderCandidateChoiceList(parent, state) {
+ const candidates = candidatesForPendingSelection(state);
+ if (candidates.length === 0) {
+ return false;
+ }
+ const list = createElement("div", "candidate-choice-list");
+ candidates.forEach((candidate, index) => {
+ const candidateIndex = candidateIndexOf(candidate, index);
+ const isSelected = state.selectedCandidateIndex === candidateIndex;
+ const choice = createElement("button", `candidate-choice${isSelected ? " selected" : ""}`);
+ if (choice) {
+ choice.setAttribute("type", "button");
+ choice.setAttribute("data-candidate-choice", String(candidateIndex));
+ choice.setAttribute("aria-pressed", isSelected ? "true" : "false");
+ choice.addEventListener("click", () => {
+ controller.state = selectCandidate(ensureState(), candidateIndex);
+ syncComposerWithSelectedCandidate(controller.state);
+ renderAll();
+ });
+ }
+ appendChild(choice, createElement("strong", "", candidate.name || `方案 ${candidateIndex}`));
+ const summary = candidate.summary || candidate.template || "";
+ if (summary) {
+ appendChild(choice, createElement("span", "", summary));
+ }
+ if (candidate.totalMonthlyCost !== "" && candidate.totalMonthlyCost !== null && candidate.totalMonthlyCost !== undefined) {
+ appendChild(choice, createElement("span", "price", candidate.totalMonthlyCost));
+ }
+ appendChild(list, choice);
+ });
+ appendChild(parent, list);
+ return true;
+ }
+
+ function pendingInputKindLabel(kind) {
+ if (kind === "candidate_selection" || kind === "candidate_select") {
+ return "请选择方案";
+ }
+ if (kind === "ask_user_question") {
+ return "需要您确认";
+ }
+ return "需要您处理";
+ }
+
+ function pendingInputPrompt(pendingInput) {
+ return pendingInput && (pendingInput.prompt || pendingInput.question || pendingInput.freeTextPrompt || pendingInput.free_text_prompt)
+ ? pendingInput.prompt || pendingInput.question || pendingInput.freeTextPrompt || pendingInput.free_text_prompt
+ : "请补充信息后继续。";
+ }
+
+ function pendingOptionId(option, index) {
+ const rawId = option && (option.id ?? option.value ?? option.candidateIndex ?? option.candidate_index ?? index);
+ return rawId === null || rawId === undefined ? String(index) : String(rawId);
+ }
+
+ function candidateIndexFromPendingOption(option, index) {
+ if (option && typeof option === "object") {
+ const nestedCandidate = option.candidate && typeof option.candidate === "object" ? option.candidate : {};
+ const rawCandidateIndex =
+ option.candidateIndex ??
+ option.candidate_index ??
+ option.optionIndex ??
+ option.option_index ??
+ nestedCandidate.index ??
+ nestedCandidate.candidateIndex ??
+ nestedCandidate.candidate_index ??
+ null;
+ if (rawCandidateIndex !== null && rawCandidateIndex !== undefined && rawCandidateIndex !== "") {
+ const numericIndex = Number(rawCandidateIndex);
+ return Number.isFinite(numericIndex) ? numericIndex : rawCandidateIndex;
+ }
+ }
+ const optionId = pendingOptionId(option, index);
+ const numericOptionId = Number(optionId);
+ return Number.isFinite(numericOptionId) ? numericOptionId : null;
+ }
+
+ function pendingOptionLabel(option, index) {
+ if (!option || typeof option !== "object") {
+ return option === 0 || option ? String(option) : `选项 ${index + 1}`;
+ }
+ return option.label || option.title || option.name || option.candidateName || `选项 ${index + 1}`;
+ }
+
+ function pendingOptionDescription(option) {
+ if (!option || typeof option !== "object") {
+ return "";
+ }
+ return [option.description || option.summary || "", option.totalMonthlyCost || option.total_monthly_cost || option.price || ""]
+ .filter(Boolean)
+ .join("");
+ }
+
+ function syncComposerWithSelectedCandidate(state) {
+ const composer = byId("composer-input");
+ if (composer && "value" in composer) {
+ composer.value = promptForSelectedCandidate(state || ensureState());
+ }
+ }
+
+ function handlePendingInputOption(option, index) {
+ const state = ensureState();
+ const pendingInput = state.pendingInput || {};
+ const kind = pendingInput.kind || "";
+ const optionId = pendingOptionId(option, index);
+ const candidateIndex = candidateIndexFromPendingOption(option, index);
+ const composer = byId("composer-input");
+ state.selectedPendingInputOptionId = optionId;
+ if (kind === "candidate_selection" || kind === "candidate_select") {
+ if (candidateIndex !== null && candidateIndex !== undefined) {
+ controller.state = selectCandidate(state, candidateIndex);
+ controller.state.selectedPendingInputOptionId = optionId;
+ syncComposerWithSelectedCandidate(controller.state);
+ } else if (composer && "value" in composer) {
+ composer.value = optionId || pendingOptionLabel(option, index);
+ }
+ renderAll();
+ return;
+ }
+ if (candidateIndex !== null && candidateIndex !== undefined) {
+ controller.state = selectCandidate(state, candidateIndex);
+ controller.state.selectedPendingInputOptionId = optionId;
+ }
+ if (composer && "value" in composer) {
+ composer.value = optionId || pendingOptionLabel(option, index);
+ }
+ renderAll();
+ }
+
+ function renderPendingInputCard(parent, state) {
+ const pendingInput = state && state.pendingInput;
+ if (!pendingInput) {
+ return;
+ }
+ const kind = pendingInput.kind || "input";
+ const isCandidateSelection = pendingInputIsCandidateSelection(pendingInput);
+ const card = createElement("section", "pending-input-card");
+ if (card) {
+ card.setAttribute("data-pending-input-kind", kind);
+ }
+ appendChild(card, createElement("h2", "", pendingInputKindLabel(kind)));
+ appendChild(card, renderMarkdownText(pendingInputPrompt(pendingInput), "pending-input-prompt"));
+ const options = Array.isArray(pendingInput.options) ? pendingInput.options : [];
+ if (options.length > 0) {
+ const optionList = createElement("div", "pending-input-options");
+ options.forEach((option, index) => {
+ const optionId = pendingOptionId(option, index);
+ const candidateIndex = candidateIndexFromPendingOption(option, index);
+ const isSelected =
+ state.selectedPendingInputOptionId === optionId ||
+ (candidateIndex !== null && candidateIndex !== undefined && state.selectedCandidateIndex === candidateIndex);
+ const optionButton = createElement("button", `pending-input-option${isSelected ? " selected" : ""}`);
+ if (optionButton) {
+ optionButton.setAttribute("type", "button");
+ optionButton.setAttribute("data-pending-input-option", optionId);
+ optionButton.setAttribute("aria-pressed", isSelected ? "true" : "false");
+ if (candidateIndex !== null && candidateIndex !== undefined) {
+ optionButton.setAttribute("data-candidate-choice", String(candidateIndex));
+ }
+ optionButton.addEventListener("click", () => handlePendingInputOption(option, index));
+ }
+ appendChild(optionButton, createElement("strong", "", pendingOptionLabel(option, index)));
+ const description = pendingOptionDescription(option);
+ if (description) {
+ appendChild(optionButton, renderMarkdownText(description, "pending-input-option-description"));
+ }
+ appendChild(optionList, optionButton);
+ });
+ appendChild(card, optionList);
+ }
+ appendChild(parent, card);
+ }
+
+ function ensureState() {
+ if (!controller.state) {
+ const defaults =
+ window.SELLING_CONSOLE_DEFAULTS && typeof window.SELLING_CONSOLE_DEFAULTS === "object"
+ ? window.SELLING_CONSOLE_DEFAULTS
+ : {};
+ controller.state = createInitialState(defaults);
+ }
+ return controller.state;
+ }
+
+ function syncConnectionControlsFromState() {
+ const state = ensureState();
+ const serverInput = byId("server-url");
+ const cwdInput = byId("cwd");
+ if (serverInput && "value" in serverInput && !serverInput.value && state.serverUrl) {
+ serverInput.value = state.serverUrl;
+ }
+ if (cwdInput && "value" in cwdInput && !cwdInput.value && state.cwd) {
+ cwdInput.value = state.cwd;
+ }
+ }
+
+ function syncStateFromConnectionControls() {
+ const state = ensureState();
+ const serverInput = byId("server-url");
+ const cwdInput = byId("cwd");
+ if (serverInput && "value" in serverInput) {
+ state.serverUrl = String(serverInput.value || "").trim();
+ }
+ if (cwdInput && "value" in cwdInput) {
+ state.cwd = String(cwdInput.value || "").trim();
+ }
+ return state;
+ }
+
+ function renderStatus() {
+ const state = ensureState();
+ const statusPill = byId("status-pill");
+ if (statusPill) {
+ statusPill.textContent = statusLabel(state.pendingInput ? "waiting_input" : state.status);
+ }
+ }
+
+ function stepModelsForProgress(state, ui, options = {}) {
+ const steps = STEP_ORDER.map((stepId, index) => {
+ const step = state.steps && state.steps[stepId] ? state.steps[stepId] : createSteps()[stepId];
+ const status = stepStatusClass(normalizeStatus(step.status) || "pending");
+ return {
+ id: stepId,
+ index,
+ label: step.label || STEP_LABELS[stepId] || stepId,
+ status,
+ };
+ });
+ if (options.useConfiguredActiveStep && ui && Number.isInteger(ui.activeStepIndex)) {
+ return { steps, activeIndex: ui.activeStepIndex };
+ }
+ const currentIndex = steps.findIndex((step) => step.status === "working" || step.status === "waiting_input");
+ if (currentIndex >= 0) {
+ return { steps, activeIndex: currentIndex };
+ }
+ const lastCompletedIndex = steps.reduce((lastIndex, step, index) => (step.status === "completed" ? index : lastIndex), -1);
+ return { steps, activeIndex: Math.max(0, lastCompletedIndex) };
+ }
+
+ function progressVisualStatus(step, activeIndex) {
+ if (step.status === "failed" || step.status === "error") {
+ return "failed";
+ }
+ if (step.status === "completed" || step.index < activeIndex) {
+ return "done";
+ }
+ if (step.index === activeIndex) {
+ return "active";
+ }
+ return "pending";
+ }
+
+ function stepTipText(step, activeIndex) {
+ const visualStatus = progressVisualStatus(step, activeIndex);
+ if (visualStatus === "done") {
+ return `${step.label}:已完成`;
+ }
+ if (visualStatus === "active") {
+ return `${step.label}:当前步骤`;
+ }
+ if (visualStatus === "failed") {
+ return `${step.label}:处理异常`;
+ }
+ return `${step.label}:等待前序步骤`;
+ }
+
+ function applyProgressRoot(progress, variant) {
+ const className = variant === "a" ? "composer-progress chevrons" : `composer-progress progress-shell progress-variant-${variant}`;
+ progress.className = className;
+ if (typeof progress.setAttribute === "function") {
+ progress.setAttribute("class", className);
+ }
+ progress.setAttribute("data-progress-variant", variant);
+ }
+
+ function debugDrawerIsOpen() {
+ const drawer = byId("debug-drawer");
+ return Boolean(drawer && drawer.open);
+ }
+
+ function hideComposerProgress(progress, ui) {
+ clearElement(progress);
+ cancelProgressAnimation();
+ progress.hidden = true;
+ progress.className = "composer-progress";
+ if (typeof progress.setAttribute === "function") {
+ progress.setAttribute("class", "composer-progress");
+ progress.setAttribute("data-progress-variant", ui.variant);
+ progress.setAttribute("data-progress-mode", "pipeline");
+ progress.setAttribute("data-progress-visible", "false");
+ }
+ }
+
+ function renderChevronProgress(progress, models, params) {
+ applyProgressRoot(progress, "a");
+ progress.setAttribute("style", `--progress-a-sweep-ms: ${params.sweepMs}ms;`);
+ models.steps.forEach((step) => {
+ const visualStatus = progressVisualStatus(step, models.activeIndex);
+ const item = createElement("div", `step ${visualStatus === "done" ? "done" : visualStatus === "active" ? "active" : ""}`);
+ if (item) {
+ item.setAttribute("data-step-index", String(step.index));
+ item.setAttribute("data-progress-step", step.id);
+ item.setAttribute("data-status", step.status);
+ item.setAttribute("title", stepTipText(step, models.activeIndex));
+ }
+ appendChild(item, document.createTextNode ? document.createTextNode(step.label) : createElement("span", "", step.label));
+ appendChild(item, createElement("span", "tip", stepTipText(step, models.activeIndex)));
+ appendChild(progress, item);
+ });
+ }
+
+ function pathLine(startX, endX, y = 22) {
+ return startX === endX ? "" : `M ${startX} ${y} L ${endX} ${y}`;
+ }
+
+ function renderSignalProgress(progress, models, params) {
+ applyProgressRoot(progress, "b");
+ const activeIndex = models.activeIndex;
+ const stepPercents = [6, 28, 50, 72, 94];
+ const stepXs = [20, 96, 172, 248, 324];
+ const railStartX = stepXs[0];
+ const railEndX = stepXs[stepXs.length - 1];
+ const previousX = activeIndex > 0 ? stepXs[activeIndex - 1] : 0;
+ const currentX = stepXs[activeIndex];
+ const nextX = activeIndex < stepXs.length - 1 ? stepXs[activeIndex + 1] : 344;
+ const shell = createElement("div", "signal-circuit");
+ if (shell) {
+ shell.setAttribute("data-active-index", String(activeIndex));
+ shell.setAttribute("style", `--absorb-duration: ${params.pauseTime}ms;`);
+ }
+ const svg = createElement("svg", "signal-svg");
+ if (svg) {
+ svg.setAttribute("viewBox", "0 0 344 44");
+ svg.setAttribute("preserveAspectRatio", "none");
+ svg.setAttribute("aria-hidden", "true");
+ [
+ ["signal-rail", pathLine(railStartX, railEndX)],
+ ["signal-done", activeIndex > 0 ? pathLine(railStartX, stepXs[activeIndex - 1]) : ""],
+ ["signal-active-base signal-active-in", pathLine(previousX, currentX)],
+ ["signal-active-base signal-active-out", pathLine(currentX, nextX)],
+ ["signal-moving-wave", ""],
+ ].forEach(([className, pathValue]) => {
+ const path = createElement("path", className);
+ if (path) {
+ path.setAttribute("d", pathValue);
+ }
+ appendChild(svg, path);
+ });
+ }
+ appendChild(shell, svg);
+ const halo = createElement("span", "signal-absorb-halo");
+ if (halo) {
+ halo.setAttribute("aria-hidden", "true");
+ halo.setAttribute("style", `left:${stepPercents[activeIndex]}%`);
+ }
+ appendChild(shell, halo);
+ models.steps.forEach((step) => {
+ const visualStatus = progressVisualStatus(step, activeIndex);
+ const nodeClass = [
+ "signal-node",
+ visualStatus === "active" ? "active" : "",
+ visualStatus === "pending" ? "pending" : "",
+ step.index === activeIndex + 1 ? "next" : "",
+ ].filter(Boolean).join(" ");
+ const node = createElement("span", nodeClass);
+ if (node) {
+ node.setAttribute("data-step-index", String(step.index));
+ node.setAttribute("data-progress-step", step.id);
+ node.setAttribute("data-status", step.status);
+ node.setAttribute("style", `left: ${stepPercents[step.index]}%`);
+ node.setAttribute("title", stepTipText(step, activeIndex));
+ }
+ appendChild(node, createElement("span", "signal-node-charge"));
+ appendChild(node, createElement("span", "signal-node-core"));
+ appendChild(shell, node);
+ });
+ const labels = createElement("div", "signal-labels");
+ models.steps.forEach((step) => {
+ const label = createElement("span", progressVisualStatus(step, activeIndex) === "active" ? "active" : "", step.label);
+ if (label) {
+ label.setAttribute("data-step-index", String(step.index));
+ label.setAttribute("style", `left: ${stepPercents[step.index]}%`);
+ }
+ appendChild(labels, label);
+ });
+ appendChild(shell, labels);
+ appendChild(progress, shell);
+ }
+
+ function renderFusionProgress(progress, models, params) {
+ applyProgressRoot(progress, "d");
+ const activeIndex = models.activeIndex;
+ const shell = createElement("div", "fusion-label");
+ if (shell) {
+ shell.setAttribute("data-active-index", String(activeIndex));
+ shell.setAttribute("style", `--fusion-sweep-duration: ${params.t1}ms;`);
+ }
+ const steps = createElement("div", "fusion-steps");
+ models.steps.forEach((step) => {
+ const visualStatus = progressVisualStatus(step, activeIndex);
+ const item = createElement("div", `fusion-step ${visualStatus === "done" ? "done" : visualStatus === "active" ? "active" : ""}`);
+ if (item) {
+ item.setAttribute("data-step-index", String(step.index));
+ item.setAttribute("data-progress-step", step.id);
+ item.setAttribute("data-status", step.status);
+ item.setAttribute("title", stepTipText(step, activeIndex));
+ }
+ appendChild(item, createElement("span", "label", step.label));
+ appendChild(item, createElement("span", "tip", stepTipText(step, activeIndex)));
+ appendChild(steps, item);
+ });
+ appendChild(shell, steps);
+ appendChild(progress, shell);
+ }
+
+ function renderNormalHandoffMessage(stepList, state) {
+ if (!stepList || !state || !state.normalHandoffReady) {
+ return false;
+ }
+ const message = createElement("article", "normal-handoff-message");
+ if (message) {
+ message.setAttribute("data-normal-handoff-message", "true");
+ message.setAttribute("role", "status");
+ }
+ appendChild(message, createElement("p", "", NORMAL_HANDOFF_TEXT));
+ appendChild(stepList, createChatMessage("system", message));
+ return true;
+ }
+
+ function createChatMessage(role, content) {
+ const messageRole = role === "user" ? "user" : "system";
+ const message = createElement("div", `chat-message ${messageRole}`);
+ if (message) {
+ message.setAttribute("data-chat-message", messageRole);
+ }
+ const avatar = createElement("span", `chat-avatar ${messageRole}`, messageRole === "user" ? "U" : "AI");
+ if (avatar) {
+ avatar.setAttribute("data-chat-avatar", messageRole);
+ }
+ const bubble = createElement("div", "chat-bubble");
+ appendChild(bubble, content);
+ appendChild(message, avatar);
+ appendChild(message, bubble);
+ return message;
+ }
+
+ function createUserMessage(text) {
+ return createChatMessage("user", createElement("p", "user-message-text", text));
+ }
+
+ function normalProcessIsExpanded(turn) {
+ const state = ensureState();
+ if (turn && turn.status === "working") {
+ return true;
+ }
+ return Boolean(state.expandedNormalProcesses && turn && state.expandedNormalProcesses[turn.id]);
+ }
+
+ function normalProcessEventLabel(kind) {
+ return {
+ thinking: "思考",
+ tool: "工具",
+ permission: "权限",
+ error: "异常",
+ }[kind] || "过程";
+ }
+
+ function renderNormalProcess(turn) {
+ const events = Array.isArray(turn && turn.events) ? turn.events : [];
+ if (!events.length) {
+ return null;
+ }
+ const details = createElement("details", "normal-process");
+ if (details) {
+ details.setAttribute("data-normal-process", turn.id);
+ details.open = normalProcessIsExpanded(turn);
+ details.addEventListener("toggle", () => {
+ if (turn.status === "working") {
+ return;
+ }
+ const state = ensureState();
+ state.expandedNormalProcesses = state.expandedNormalProcesses || {};
+ state.expandedNormalProcesses[turn.id] = Boolean(details.open);
+ });
+ }
+ const summary = createElement("summary", "normal-process-summary");
+ appendChild(summary, createElement("span", "normal-process-title", "思考过程"));
+ appendChild(summary, createElement("span", "normal-process-count", `${events.length} 条`));
+ appendChild(details, summary);
+ const list = createElement("ul", "normal-process-events");
+ events.forEach((event) => {
+ const kind = event && event.kind ? String(event.kind) : "event";
+ const item = createElement("li", `normal-process-event ${kind}`);
+ if (item) {
+ item.setAttribute("data-normal-process-event", kind);
+ }
+ appendChild(item, createElement("span", "normal-process-event-label", event.label || normalProcessEventLabel(kind)));
+ appendChild(item, createElement("p", "", event.text || ""));
+ appendChild(list, item);
+ });
+ appendChild(details, list);
+ return details;
+ }
+
+ function renderNormalTurn(stepList, turn, renderedTurnIds) {
+ if (!stepList || !turn || (renderedTurnIds && renderedTurnIds.has(turn.id))) {
+ return;
+ }
+ const content = createElement("article", `normal-turn ${turn.status || "completed"}`);
+ if (content) {
+ content.setAttribute("data-normal-turn", turn.id);
+ }
+ appendChild(content, renderNormalProcess(turn));
+ const answer = createElement(
+ "p",
+ "normal-answer",
+ turn.answer || (turn.status === "working" ? "正在整理回复..." : "")
+ );
+ if (answer) {
+ answer.setAttribute("data-normal-answer", turn.id);
+ }
+ appendChild(content, answer);
+ appendChild(stepList, createChatMessage("system", content));
+ if (renderedTurnIds) {
+ renderedTurnIds.add(turn.id);
+ }
+ }
+
+ function userMessageKey(item, index) {
+ return item && item.id ? String(item.id) : `user-message-${index}`;
+ }
+
+ function userMessagePlacement(item) {
+ const placement = item && item.placement && typeof item.placement === "object" ? item.placement : {};
+ if (placement.position === "after_normal_handoff" || placement.after === "normal_handoff") {
+ return { position: "after_normal_handoff" };
+ }
+ if (placement.afterStepId || item.afterStepId) {
+ return { position: "after_step", afterStepId: placement.afterStepId || item.afterStepId };
+ }
+ return { position: "start" };
+ }
+
+ function messageBelongsToPosition(item, position, value) {
+ const placement = userMessagePlacement(item);
+ if (position === "start") {
+ return placement.position === "start";
+ }
+ if (position === "after_normal_handoff") {
+ return placement.position === "after_normal_handoff";
+ }
+ if (position === "after_step") {
+ return placement.position === "after_step" && placement.afterStepId === value;
+ }
+ return false;
+ }
+
+ function renderUserMessages(stepList, state, position, value, renderedKeys) {
+ const messages = Array.isArray(state && state.userMessages) ? state.userMessages : [];
+ messages.forEach((item, index) => {
+ const key = userMessageKey(item, index);
+ if (renderedKeys && renderedKeys.has(key)) {
+ return;
+ }
+ if (!messageBelongsToPosition(item, position, value)) {
+ return;
+ }
+ const text = item && item.text ? String(item.text) : "";
+ if (!text) {
+ return;
+ }
+ appendChild(stepList, createUserMessage(text));
+ if (renderedKeys) {
+ renderedKeys.add(key);
+ }
+ });
+ }
+
+ function renderNormalHandoffConversation(stepList, state, renderedKeys) {
+ const messages = Array.isArray(state && state.userMessages) ? state.userMessages : [];
+ const turns = Array.isArray(state && state.normalTurns) ? state.normalTurns : [];
+ const renderedTurnIds = new Set();
+ messages.forEach((item, index) => {
+ const key = userMessageKey(item, index);
+ if (renderedKeys && renderedKeys.has(key)) {
+ return;
+ }
+ if (!messageBelongsToPosition(item, "after_normal_handoff", "")) {
+ return;
+ }
+ const text = item && item.text ? String(item.text) : "";
+ if (!text) {
+ return;
+ }
+ appendChild(stepList, createUserMessage(text));
+ if (renderedKeys) {
+ renderedKeys.add(key);
+ }
+ turns
+ .filter((turn) => turn && turn.afterUserMessageId === key)
+ .forEach((turn) => renderNormalTurn(stepList, turn, renderedTurnIds));
+ });
+ turns
+ .filter((turn) => turn && !renderedTurnIds.has(turn.id))
+ .forEach((turn) => renderNormalTurn(stepList, turn, renderedTurnIds));
+ }
+
+ function userMessagePlacementForState(state) {
+ if (state && state.normalHandoffReady) {
+ return { position: "after_normal_handoff" };
+ }
+ if (state && pendingInputIsCandidateSelection(state.pendingInput)) {
+ return { position: "after_step", afterStepId: "confirm_and_select" };
+ }
+ const steps = (state && state.steps) || {};
+ const activeStepId = STEP_ORDER.find((stepId) => {
+ const status = stepStatusClass(normalizeStatus(steps[stepId] && steps[stepId].status));
+ return status === "working" || status === "waiting_input";
+ });
+ if (state && state.pendingInput && activeStepId) {
+ return { position: "after_step", afterStepId: activeStepId };
+ }
+ return { position: "start" };
+ }
+
+ function renderSteps() {
+ const state = ensureState();
+ const stepList = byId("step-list");
+ if (!stepList || !canCreateElements()) {
+ return;
+ }
+ clearElement(stepList);
+ const renderedUserMessages = new Set();
+ renderUserMessages(stepList, state, "start", "", renderedUserMessages);
+ STEP_ORDER.forEach((stepId, index) => {
+ const step = state.steps && state.steps[stepId] ? state.steps[stepId] : createSteps()[stepId];
+ if (!stepIsVisible(step)) {
+ return;
+ }
+ const status = stepStatusClass(normalizeStatus(step.status) || "pending");
+ const isCurrent = stepIsOpen(status);
+ const isExpanded = stepDetailsExpanded(stepId, status);
+ const card = createElement("article", `step-card ${status}${isCurrent ? " current" : ""}`);
+ const marker = createElement("span", "step-index");
+ const body = createElement("div", "step-card-body");
+ const title = createElement("h2", "", step.label || STEP_LABELS[stepId] || stepId);
+ if (card) {
+ card.setAttribute("data-step-id", stepId);
+ card.setAttribute("data-status", status);
+ if (isCurrent) {
+ card.setAttribute("aria-current", "step");
+ }
+ }
+ const iconText = stepStateIcon(status);
+ if (iconText) {
+ const icon = createElement("span", `step-state-icon ${status}`, iconText);
+ if (icon) {
+ icon.setAttribute("data-step-state-icon", status);
+ }
+ appendChild(marker, icon);
+ }
+ if (stepCanToggle(status)) {
+ const toggle = createElement("button", "step-toggle");
+ if (toggle) {
+ toggle.setAttribute("type", "button");
+ toggle.setAttribute("data-step-toggle", stepId);
+ toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
+ toggle.addEventListener("click", () => toggleStepDetails(stepId));
+ }
+ appendChild(toggle, title);
+ appendChild(toggle, createElement("span", `step-toggle-icon${isExpanded ? " expanded" : ""}`));
+ appendChild(body, toggle);
+ } else {
+ appendChild(body, title);
+ }
+ appendChild(card, marker);
+ appendChild(card, body);
+ renderStepDetails(card, step, status, isExpanded);
+ appendChild(stepList, createChatMessage("system", card));
+ renderUserMessages(stepList, state, "after_step", stepId, renderedUserMessages);
+ });
+ if (renderNormalHandoffMessage(stepList, state)) {
+ renderNormalHandoffConversation(stepList, state, renderedUserMessages);
+ }
+ renderUserMessages(stepList, state, "after_step", "", renderedUserMessages);
+ if (stepList.children && stepList.children.length > 0) {
+ scrollElementToBottom(stepList);
+ }
+ }
+
+ function renderComposerProgress() {
+ const state = ensureState();
+ const progress = byId("composer-progress");
+ if (!progress || !canCreateElements()) {
+ return;
+ }
+ clearElement(progress);
+ const ui = mergeProgressUi(state.progressUi);
+ state.progressUi = ui;
+ const isDebugPreview = debugDrawerIsOpen();
+ if (!isDebugPreview && !state.pipelineStarted) {
+ hideComposerProgress(progress, ui);
+ return;
+ }
+ progress.hidden = false;
+ progress.setAttribute("data-progress-mode", isDebugPreview ? "debug" : "pipeline");
+ progress.setAttribute("data-progress-visible", "true");
+ const models = stepModelsForProgress(state, ui, { useConfiguredActiveStep: isDebugPreview });
+ if (ui.variant === "a") {
+ renderChevronProgress(progress, models, ui.a);
+ } else if (ui.variant === "d") {
+ renderFusionProgress(progress, models, ui.d);
+ } else {
+ renderSignalProgress(progress, models, ui.b);
+ }
+ startProgressAnimation();
+ }
+
+ function smoothstep(edge0, edge1, value) {
+ if (edge0 === edge1) {
+ return value < edge0 ? 0 : 1;
+ }
+ const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
+ return t * t * (3 - 2 * t);
+ }
+
+ function cancelProgressAnimation() {
+ controller.progressAnimationToken += 1;
+ if (controller.progressAnimationFrame !== null && typeof cancelAnimationFrame === "function") {
+ cancelAnimationFrame(controller.progressAnimationFrame);
+ }
+ if (typeof window !== "undefined" && window.clearTimeout) {
+ window.clearTimeout(controller.progressRunTimer);
+ window.clearTimeout(controller.progressWaitTimer);
+ }
+ controller.progressAnimationFrame = null;
+ controller.progressRunTimer = 0;
+ controller.progressWaitTimer = 0;
+ }
+
+ function startFusionProgressAnimation(progress, ui) {
+ const label = progress.querySelector ? progress.querySelector(".fusion-label") : null;
+ if (!label || typeof requestAnimationFrame !== "function") {
+ return;
+ }
+ const activeIndex = Number(label.getAttribute("data-active-index"));
+ const timing = ui.d;
+
+ const percent = (value) => `${Math.max(0, Math.min(100, value)).toFixed(2)}%`;
+ const syncBorder = () => {
+ const activeStep = label.querySelector(`.fusion-step[data-step-index="${activeIndex}"]`);
+ if (!activeStep || !label.getBoundingClientRect || !activeStep.getBoundingClientRect) {
+ return;
+ }
+ const labelRect = label.getBoundingClientRect();
+ const activeRect = activeStep.getBoundingClientRect();
+ if (!labelRect.width) {
+ return;
+ }
+ const activeStart = ((activeRect.left - labelRect.left) / labelRect.width) * 100;
+ const activeEnd = ((activeRect.right - labelRect.left) / labelRect.width) * 100;
+ const blueStart = activeIndex === 0 ? 0 : activeStart;
+ const greenEnd = activeIndex === 0 ? 0 : activeStart;
+ const blueEnd = activeIndex === STEP_ORDER.length - 1 ? 100 : activeEnd;
+ label.style.setProperty("--fusion-green-end", percent(greenEnd));
+ label.style.setProperty("--fusion-blue-start", percent(blueStart));
+ label.style.setProperty("--fusion-blue-end", percent(blueEnd));
+ label.style.setProperty("--fusion-sweep-duration", `${timing.t1}ms`);
+ };
+
+ const restartSweeps = () => {
+ window.clearTimeout(controller.progressRunTimer);
+ window.clearTimeout(controller.progressWaitTimer);
+ label.classList.remove("sweep-wait");
+ label.classList.add("sweep-reset");
+ void label.offsetWidth;
+ label.classList.remove("sweep-reset");
+ controller.progressRunTimer = window.setTimeout(() => {
+ label.classList.add("sweep-wait");
+ controller.progressWaitTimer = window.setTimeout(restartSweeps, timing.t2);
+ }, timing.t1);
+ };
+
+ requestAnimationFrame(() => {
+ syncBorder();
+ restartSweeps();
+ });
+ }
+
+ function startSignalProgressAnimation(progress, ui) {
+ if (typeof requestAnimationFrame !== "function") {
+ return;
+ }
+ const wave = progress.querySelector ? progress.querySelector(".signal-moving-wave") : null;
+ const demo = progress.querySelector ? progress.querySelector(".signal-circuit") : null;
+ if (!wave || !demo) {
+ return;
+ }
+
+ const params = ui.b;
+ const stepXs = [20, 96, 172, 248, 324];
+ const baseY = 22;
+ const viewMinX = 0;
+ const viewMaxX = 344;
+ const virtualPadding = 66;
+ const virtualLeftX = stepXs[0] - virtualPadding;
+ const virtualRightX = stepXs[stepXs.length - 1] + virtualPadding;
+ const nodeClearance = 10;
+ const outboundTailClearance = 6;
+ let activeIndex = Number(demo.getAttribute("data-active-index"));
+ let phase = "inbound";
+ let elapsed = 0;
+ let pauseLeft = 0;
+ let last = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
+ let cycleSalt = 0;
+ let absorbTimer = 0;
+ const token = controller.progressAnimationToken;
+
+ const clampToView = (x) => Math.max(viewMinX, Math.min(viewMaxX, x));
+ const inboundSegment = () => {
+ const currentX = stepXs[activeIndex];
+ return {
+ from: activeIndex === 0 ? virtualLeftX : stepXs[activeIndex - 1] + nodeClearance,
+ to: currentX - nodeClearance,
+ color: "#1677ff",
+ nextPhase: "pause-current",
+ };
+ };
+ const outboundSegment = () => {
+ const currentX = stepXs[activeIndex];
+ return {
+ from: currentX + nodeClearance,
+ to: activeIndex === stepXs.length - 1 ? virtualRightX : stepXs[activeIndex + 1] - outboundTailClearance,
+ color: "#8f9bae",
+ nextPhase: "pause-next",
+ };
+ };
+ const currentSegment = () => (phase === "outbound" || phase === "pause-next" ? outboundSegment() : inboundSegment());
+ const segmentMotion = (timeMs) => {
+ const x = Math.max(0.04, Math.min(0.48, params.xPercent / 100));
+ const y = Math.max(0, Math.min(1, params.yPercent / 100));
+ const t1 = Math.max(40, params.t1);
+ const t2 = Math.max(80, params.t2);
+ if (timeMs < t1) {
+ const u = Math.max(0, Math.min(1, timeMs / t1));
+ return { anchor: "right", progress: x * u, amplitudeScale: y * smoothstep(0, 1, u), done: false };
+ }
+ if (timeMs < t1 + t2) {
+ const u = Math.max(0, Math.min(1, (timeMs - t1) / t2));
+ return { anchor: "right", progress: x + (1 - x) * u, amplitudeScale: y + (1 - y) * Math.sin(Math.PI * u), done: false };
+ }
+ if (timeMs < t1 * 2 + t2) {
+ const u = Math.max(0, Math.min(1, (timeMs - t1 - t2) / t1));
+ return { anchor: "left", progress: 1 - x + x * u, amplitudeScale: y * (1 - smoothstep(0, 1, u)), done: false };
+ }
+ return { anchor: "left", progress: 1, amplitudeScale: 0, done: true };
+ };
+ const pulseShape = (t) => {
+ const micro = 0.1 * Math.sin((t * 2.6 + cycleSalt) * Math.PI);
+ const lift = Math.sin(Math.PI * smoothstep(0.16, 0.38, t));
+ const drop = Math.sin(Math.PI * smoothstep(0.37, 0.62, t));
+ const settle = 0.2 * Math.sin((t - 0.62) * Math.PI * 4.5 + cycleSalt * 0.4);
+ return micro + lift - drop * 0.86 + settle * smoothstep(0.58, 0.96, t);
+ };
+ const movingWavePath = () => {
+ if (phase === "pause-current" || phase === "pause-next") {
+ return "";
+ }
+ const segment = currentSegment();
+ const segmentLength = segment.to - segment.from;
+ const xRatio = Math.max(0.04, Math.min(0.48, params.xPercent / 100));
+ const waveLength = segmentLength * xRatio;
+ const motion = segmentMotion(elapsed);
+ const amplitude = params.maxAmplitude * motion.amplitudeScale;
+ if (amplitude < 0.2) {
+ return "";
+ }
+ const right =
+ motion.anchor === "left"
+ ? segment.from + motion.progress * segmentLength + waveLength
+ : segment.from + motion.progress * segmentLength;
+ const left = motion.anchor === "left" ? segment.from + motion.progress * segmentLength : right - waveLength;
+ const start = Math.max(segment.from, left);
+ const end = Math.min(segment.to, right);
+ if (end <= segment.from || start >= segment.to || end - start < 1) {
+ return "";
+ }
+ const points = [];
+ const samples = 54;
+ for (let i = 0; i <= samples; i += 1) {
+ const t = i / samples;
+ const x = start + t * (end - start);
+ const packetT = left < segment.from ? t : (x - left) / waveLength;
+ const envelope = smoothstep(0, 0.16, packetT) * (1 - smoothstep(0.84, 1, packetT));
+ const y = baseY - pulseShape(packetT) * amplitude * envelope;
+ points.push(`${i === 0 ? "M" : "L"} ${clampToView(x).toFixed(2)} ${y.toFixed(2)}`);
+ }
+ return points.join(" ");
+ };
+ const render = () => {
+ const segment = currentSegment();
+ wave.style.stroke = segment.color;
+ wave.setAttribute("d", movingWavePath());
+ };
+ const triggerAbsorbHalo = () => {
+ demo.classList.remove("absorbing");
+ window.clearTimeout(absorbTimer);
+ void demo.offsetWidth;
+ demo.classList.add("absorbing");
+ absorbTimer = window.setTimeout(() => {
+ demo.classList.remove("absorbing");
+ }, params.pauseTime);
+ };
+ const tick = (now) => {
+ if (token !== controller.progressAnimationToken) {
+ return;
+ }
+ const dt = Math.min(48, now - last) / 1000;
+ last = now;
+ if (phase === "pause-current" || phase === "pause-next") {
+ pauseLeft -= dt * 1000;
+ if (pauseLeft <= 0) {
+ if (phase === "pause-current") {
+ demo.classList.remove("absorbing");
+ window.clearTimeout(absorbTimer);
+ }
+ phase = phase === "pause-current" ? "outbound" : "inbound";
+ elapsed = 0;
+ cycleSalt = (cycleSalt + 0.73) % (Math.PI * 2);
+ }
+ render();
+ controller.progressAnimationFrame = requestAnimationFrame(tick);
+ return;
+ }
+ const segment = currentSegment();
+ elapsed += dt * 1000;
+ if (segmentMotion(elapsed).done) {
+ pauseLeft = params.pauseTime;
+ phase = segment.nextPhase;
+ if (phase === "pause-current") {
+ triggerAbsorbHalo();
+ }
+ elapsed = params.t1 * 2 + params.t2;
+ }
+ render();
+ controller.progressAnimationFrame = requestAnimationFrame(tick);
+ };
+
+ requestAnimationFrame((now) => {
+ last = now;
+ render();
+ tick(now);
+ });
+ }
+
+ function startProgressAnimation() {
+ cancelProgressAnimation();
+ const progress = byId("composer-progress");
+ if (!progress || progress.hidden) {
+ return;
+ }
+ const ui = mergeProgressUi(ensureState().progressUi);
+ if (progress.getAttribute("data-progress-variant") === "b") {
+ startSignalProgressAnimation(progress, ui);
+ }
+ if (progress.getAttribute("data-progress-variant") === "d") {
+ startFusionProgressAnimation(progress, ui);
+ }
+ }
+
+ function costItemLabel(item) {
+ if (!item || typeof item !== "object") {
+ return "";
+ }
+ const name = item.name || item.resource || item.type || item.product || "费用项";
+ const spec = item.spec || item.instanceType || item.instance_type || item.description || "";
+ const cost = item.monthly_cost ?? item.monthlyCost ?? item.totalMonthlyCost ?? item.cost ?? "";
+ return [name, spec, cost].filter((value) => value !== "" && value !== null && value !== undefined).join(" · ");
+ }
+
+ function presentValue(value, fallback) {
+ if (value === 0 || value) {
+ return String(value);
+ }
+ return fallback;
+ }
+
+ function candidateSubStepOf(event) {
+ const data = eventData(event);
+ return (
+ (event && event.candidateStep && typeof event.candidateStep === "object" ? event.candidateStep : null) ||
+ (event && event.candidate_step && typeof event.candidate_step === "object" ? event.candidate_step : null) ||
+ (data.candidateStep && typeof data.candidateStep === "object" ? data.candidateStep : null) ||
+ (data.candidate_step && typeof data.candidate_step === "object" ? data.candidate_step : null) ||
+ {}
+ );
+ }
+
+ function candidateSubStepLabel(event) {
+ const subStep = candidateSubStepOf(event);
+ const rawLabel = subStep.label || subStep.name || subStep.title || subStep.id || "";
+ const normalizedLabel = String(rawLabel || "").trim();
+ if (CANDIDATE_SUBSTEP_LABELS[normalizedLabel]) {
+ return CANDIDATE_SUBSTEP_LABELS[normalizedLabel];
+ }
+ return normalizedLabel || "方案思考";
+ }
+
+ function candidateSubEventKind(event) {
+ const eventType = eventTypeOf(event || {});
+ return String(eventType || "").startsWith("candidate_step") ? eventType : stepEventKind(event);
+ }
+
+ function isCandidateLifecycleEvent(event) {
+ const eventType = eventTypeOf(event || {});
+ return eventType === "candidate_started" || eventType === "candidate_completed" || eventType === "candidate_failed";
+ }
+
+ function candidateRenderableSubEvents(events) {
+ return (Array.isArray(events) ? events : []).filter((event) => !isCandidateLifecycleEvent(event));
+ }
+
+ function candidateSubEventLabel(kind) {
+ const labels = {
+ candidate_step_completed: "子步骤完成",
+ candidate_step_failed: "子步骤失败",
+ candidate_step_started: "子步骤开始",
+ candidate_started: "方案开始",
+ candidate_completed: "方案完成",
+ candidate_failed: "方案异常",
+ text_delta: "思考片段",
+ tool_result: "工具结果",
+ tool_use: "工具调用",
+ };
+ return labels[kind] || stepEventLabel(kind);
+ }
+
+ function candidateSubPipelineState(candidate) {
+ const events = Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : [];
+ const latest = events[events.length - 1];
+ const eventType = eventTypeOf(latest || {});
+ const status = normalizeStatus((latest && latest.status) || candidateSubStepOf(latest).status || "");
+ if (eventType === "candidate_completed") {
+ return "completed";
+ }
+ if (eventType === "candidate_failed") {
+ return "failed";
+ }
+ if (eventType === "candidate_step_failed" || status === "failed" || status === "error") {
+ return "failed";
+ }
+ return "working";
+ }
+
+ function candidateSubPipelineStatus(candidate) {
+ const state = candidateSubPipelineState(candidate);
+ if (state === "completed") {
+ return "思考完成";
+ }
+ if (state === "failed") {
+ return "思考异常";
+ }
+ return "思考中";
+ }
+
+ function candidatePlanStatus(candidate) {
+ const events = Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : [];
+ if (events.length === 0) {
+ return null;
+ }
+ const state = candidateSubPipelineState(candidate);
+ if (state === "completed") {
+ return { state: "completed", label: "已完成" };
+ }
+ if (state === "failed") {
+ return { state: "failed", label: "异常" };
+ }
+ return { state: "working", label: "生成中" };
+ }
+
+ function candidateSubStepId(event, fallbackIndex) {
+ const subStep = candidateSubStepOf(event);
+ return String(subStep.id || subStep.stepId || subStep.name || subStep.label || `step-${fallbackIndex}`);
+ }
+
+ function candidateSubStepStatus(events, forceComplete = false) {
+ const latest = events[events.length - 1];
+ const eventType = eventTypeOf(latest || {});
+ const status = normalizeStatus((latest && latest.status) || candidateSubStepOf(latest).status || "");
+ if (eventType === "candidate_step_completed" || status === "completed" || forceComplete) {
+ return "completed";
+ }
+ if (eventType === "candidate_step_failed" || status === "failed" || status === "error") {
+ return "failed";
+ }
+ return "working";
+ }
+
+ function groupCandidateSubEvents(events, options = {}) {
+ const forceComplete = Boolean(options.forceComplete);
+ const groups = [];
+ events.forEach((event, index) => {
+ const id = candidateSubStepId(event, index);
+ let group = groups.find((item) => item.id === id);
+ if (!group) {
+ group = {
+ id,
+ label: candidateSubStepLabel(event),
+ events: [],
+ };
+ groups.push(group);
+ }
+ group.events.push(event);
+ group.label = group.label || candidateSubStepLabel(event);
+ group.status = candidateSubStepStatus(group.events, forceComplete);
+ });
+ return groups;
+ }
+
+ function candidateEvaluationIsComplete() {
+ const state = ensureState();
+ const steps = state.steps || {};
+ const evaluationStatus = stepStatusClass(normalizeStatus(steps.evaluate_candidates && steps.evaluate_candidates.status));
+ const selectionStatus = stepStatusClass(normalizeStatus(steps.confirm_and_select && steps.confirm_and_select.status));
+ const deploymentStatus = stepStatusClass(normalizeStatus(steps.deploying && steps.deploying.status));
+ return (
+ evaluationStatus === "completed" ||
+ ["working", "waiting_input", "completed"].includes(selectionStatus) ||
+ ["working", "waiting_input", "completed"].includes(deploymentStatus)
+ );
+ }
+
+ function candidateEvaluationIsWorking() {
+ const state = ensureState();
+ const steps = state.steps || {};
+ return stepStatusClass(normalizeStatus(steps.evaluate_candidates && steps.evaluate_candidates.status)) === "working";
+ }
+
+ function scrollElementToBottom(element) {
+ if (!element || typeof element.scrollTop === "undefined") {
+ return;
+ }
+ const scroll = () => {
+ element.scrollTop = element.scrollHeight || 0;
+ };
+ scroll();
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(scroll);
+ }
+ }
+
+ function renderCandidateSubstepGroup(group) {
+ const substep = createElement("details", "candidate-substep");
+ if (substep) {
+ substep.setAttribute("data-candidate-substep", group.id);
+ substep.open = group.status !== "completed";
+ }
+ const substepHead = createElement("summary", "candidate-substep-head");
+ appendChild(substepHead, createElement("strong", "", group.label));
+ appendChild(substepHead, createElement("span", "", group.status === "completed" ? "完成" : group.status === "failed" ? "异常" : "进行中"));
+ appendChild(substep, substepHead);
+ const list = createElement("ul", "candidate-subpipeline-events");
+ group.events.forEach((event) => {
+ const kind = candidateSubEventKind(event);
+ const item = createElement("li", `candidate-subpipeline-event ${kind}`);
+ if (item) {
+ item.setAttribute("data-candidate-subpipeline-event", kind);
+ }
+ appendChild(item, createElement("span", "candidate-subpipeline-label", candidateSubEventLabel(kind)));
+ appendChild(item, createElement("p", "", eventTitle(event)));
+ appendChild(list, item);
+ });
+ appendChild(substep, list);
+ return substep;
+ }
+
+ function renderCandidateSubPipeline(card, candidate, candidateIndex) {
+ const events = compactDisplayEvents(Array.isArray(candidate && candidate.subEvents) ? candidate.subEvents : []);
+ const renderableEvents = candidateRenderableSubEvents(events);
+ if (renderableEvents.length === 0) {
+ return;
+ }
+ const state = ensureState();
+ const pipelineKey = String(candidateIndex);
+ const pipelineState = candidateSubPipelineState(candidate);
+ const shouldAutoOpen = candidateEvaluationIsWorking() && pipelineState === "working";
+ const section = createElement("details", "candidate-subpipeline");
+ if (section) {
+ section.setAttribute("data-candidate-subpipeline", pipelineKey);
+ section.open = shouldAutoOpen || Boolean(state.expandedCandidateSubpipelines && state.expandedCandidateSubpipelines[pipelineKey]);
+ section.addEventListener("click", (event) => {
+ if (event && typeof event.stopPropagation === "function") {
+ event.stopPropagation();
+ }
+ });
+ section.addEventListener("toggle", () => {
+ const nextState = ensureState();
+ nextState.expandedCandidateSubpipelines = nextState.expandedCandidateSubpipelines || {};
+ nextState.expandedCandidateSubpipelines[pipelineKey] = Boolean(section.open);
+ });
+ section.addEventListener("keydown", (event) => {
+ if (event && (event.key === "Enter" || event.key === " ")) {
+ event.stopPropagation();
+ }
+ });
+ }
+ const head = createElement("summary", "candidate-subpipeline-head");
+ if (head) {
+ head.setAttribute("data-candidate-subpipeline-toggle", String(candidateIndex));
+ }
+ appendChild(head, createElement("strong", "", "思考过程"));
+ appendChild(head, createElement("span", "candidate-subpipeline-arrow"));
+ appendChild(section, head);
+ const body = createElement("div", "candidate-subpipeline-body");
+ if (body) {
+ body.setAttribute("data-candidate-subpipeline-body", pipelineKey);
+ }
+ const substeps = createElement("div", "candidate-substeps");
+ groupCandidateSubEvents(renderableEvents, { forceComplete: pipelineState === "completed" || candidateEvaluationIsComplete() }).forEach((group) => {
+ appendChild(substeps, renderCandidateSubstepGroup(group));
+ });
+ appendChild(body, substeps);
+ appendChild(section, body);
+ appendChild(card, section);
+ if (section && section.open) {
+ scrollElementToBottom(body);
+ }
+ }
+
+ function candidateIndexOf(candidate, fallbackIndex) {
+ const rawIndex = candidate && candidate.candidateIndex !== null && candidate.candidateIndex !== undefined
+ ? candidate.candidateIndex
+ : fallbackIndex;
+ const numericIndex = Number(rawIndex);
+ return Number.isFinite(numericIndex) ? numericIndex : fallbackIndex;
+ }
+
+ function renderPlans() {
+ const state = ensureState();
+ const plansGrid = byId("plans-grid");
+ if (!plansGrid || !canCreateElements()) {
+ return;
+ }
+ clearElement(plansGrid);
+ (Array.isArray(state.candidates) ? state.candidates : []).forEach((candidate, index) => {
+ const candidateIndex = candidateIndexOf(candidate, index);
+ const isSelected = state.selectedCandidateIndex === candidateIndex;
+ const isRecommended = isSelected || (state.selectedCandidateIndex === null && index === 0);
+ const cardClasses = ["plan-card", isSelected ? "selected" : "", isRecommended ? "recommended" : ""]
+ .filter(Boolean)
+ .join(" ");
+ const card = createElement("article", cardClasses);
+ const header = createElement("div", "plan-card-header");
+ const tag = createElement("span", `tag${isRecommended ? "" : " muted"}`, isSelected ? "已选" : index === 0 ? "推荐" : "备选");
+ const score = createElement("span", "score", `方案 ${candidateIndex}`);
+ const planStatus = candidatePlanStatus(candidate);
+ const title = createElement("h2", "", candidate.name || `方案 ${candidateIndex}`);
+ const summary = createElement("p", "", candidate.summary || "等待方案摘要");
+ const price = createElement("div", "price");
+ const meta = createElement("dl", "plan-meta");
+ const costItems = Array.isArray(candidate.costItems) ? candidate.costItems : [];
+ const templateHoverText = isTemplateLikeText(candidate.template) ? String(candidate.template) : "";
+
+ if (card) {
+ card.setAttribute("role", "button");
+ card.setAttribute("tabindex", "0");
+ card.setAttribute("aria-pressed", isSelected ? "true" : "false");
+ card.setAttribute("data-candidate-index", String(candidateIndex));
+ card.addEventListener("click", () => {
+ controller.state = selectCandidate(ensureState(), candidateIndex);
+ syncComposerWithSelectedCandidate(controller.state);
+ renderAll();
+ });
+ card.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ controller.state = selectCandidate(ensureState(), candidateIndex);
+ syncComposerWithSelectedCandidate(controller.state);
+ renderAll();
+ }
+ });
+ }
+
+ appendChild(header, tag);
+ const headerMeta = createElement("div", "plan-card-header-meta");
+ appendChild(headerMeta, score);
+ if (planStatus) {
+ const status = createElement("span", `plan-status ${planStatus.state}`, planStatus.label);
+ if (status) {
+ status.setAttribute("data-candidate-status", planStatus.state);
+ }
+ appendChild(headerMeta, status);
+ }
+ appendChild(header, headerMeta);
+ appendChild(card, header);
+ appendChild(card, title);
+ appendChild(card, summary);
+ appendChild(price, createElement("span", "price-label", "预估价格"));
+ appendChild(price, createElement("strong", "", presentValue(candidate.totalMonthlyCost, "价格待确认")));
+ appendChild(card, price);
+
+ costItems.slice(0, 4).forEach((item) => {
+ const row = createElement("div");
+ const term = createElement("dt", "", item && (item.name || item.resource || item.product) ? item.name || item.resource || item.product : "资源");
+ const detail = createElement("dd", "", costItemLabel(item));
+ appendChild(row, term);
+ appendChild(row, detail);
+ appendChild(meta, row);
+ });
+ appendChild(card, meta);
+ renderCandidateSubPipeline(card, candidate, candidateIndex);
+ attachTemplatePopover(card, templateHoverText);
+ appendChild(plansGrid, card);
+ });
+ }
+
+ function formatProgressParamValue(definition, value) {
+ const numericValue = Number(value);
+ const rendered = Number.isFinite(numericValue) && definition.step < 1 ? numericValue.toFixed(1) : String(value);
+ return `${rendered}${definition.unit || ""}`;
+ }
+
+ function setProgressVariant(variant) {
+ const state = ensureState();
+ const ui = mergeProgressUi(state.progressUi);
+ if (PROGRESS_VARIANT_ORDER.includes(variant)) {
+ ui.variant = variant;
+ }
+ state.progressUi = ui;
+ renderAll();
+ }
+
+ function setProgressParam(variant, key, value) {
+ const state = ensureState();
+ const ui = mergeProgressUi(state.progressUi);
+ if (!PROGRESS_VARIANT_ORDER.includes(variant) || !Object.prototype.hasOwnProperty.call(ui[variant], key)) {
+ return;
+ }
+ const numericValue = Number(value);
+ if (Number.isFinite(numericValue)) {
+ ui[variant][key] = numericValue;
+ state.progressUi = ui;
+ renderAll();
+ }
+ }
+
+ function setProgressStep(index) {
+ const state = ensureState();
+ const ui = mergeProgressUi(state.progressUi);
+ const numericIndex = Number(index);
+ ui.activeStepIndex = Number.isInteger(numericIndex) && numericIndex >= 0 && numericIndex < STEP_ORDER.length ? numericIndex : null;
+ state.progressUi = ui;
+ renderAll();
+ }
+
+ function renderProgressDebugPanel() {
+ const panel = byId("progress-debug-panel");
+ if (!panel || !canCreateElements()) {
+ return;
+ }
+ const state = ensureState();
+ const ui = mergeProgressUi(state.progressUi);
+ state.progressUi = ui;
+ clearElement(panel);
+
+ const title = createElement("div", "progress-debug-title");
+ appendChild(title, createElement("strong", "", "进度条方案"));
+ appendChild(title, createElement("span", "", "用于切换视觉方案与调参,不影响 pipeline 状态"));
+ appendChild(panel, title);
+
+ const variants = createElement("div", "progress-variant-switch");
+ PROGRESS_VARIANT_ORDER.forEach((variant) => {
+ const button = createElement("button", ui.variant === variant ? "selected" : "", PROGRESS_VARIANT_LABELS[variant]);
+ if (button) {
+ button.setAttribute("type", "button");
+ button.setAttribute("data-progress-variant-option", variant);
+ button.setAttribute("aria-pressed", ui.variant === variant ? "true" : "false");
+ button.addEventListener("click", () => setProgressVariant(variant));
+ }
+ appendChild(variants, button);
+ });
+ appendChild(panel, variants);
+
+ const activeIndex = stepModelsForProgress(state, ui, { useConfiguredActiveStep: true }).activeIndex;
+ const stepControl = createElement("div", "demo-step-control progress-demo-step-control");
+ const stepLabel = createElement("label");
+ appendChild(stepLabel, createElement("span", "", "演示 Step"));
+ appendChild(stepLabel, createElement("output", "", STEP_LABELS[STEP_ORDER[activeIndex]]));
+ appendChild(stepControl, stepLabel);
+ const stepSwitch = createElement("div", "step-switch");
+ if (stepSwitch) {
+ stepSwitch.setAttribute("aria-label", "进度条演示当前步骤");
+ }
+ STEP_ORDER.forEach((stepId, index) => {
+ const button = createElement("button", index === activeIndex ? "active" : "", String(index + 1));
+ if (button) {
+ button.setAttribute("type", "button");
+ button.setAttribute("data-progress-step-option", String(index));
+ button.setAttribute("aria-pressed", index === activeIndex ? "true" : "false");
+ button.setAttribute("title", STEP_LABELS[stepId]);
+ button.addEventListener("click", () => setProgressStep(index));
+ }
+ appendChild(stepSwitch, button);
+ });
+ appendChild(stepControl, stepSwitch);
+ appendChild(panel, stepControl);
+
+ PROGRESS_VARIANT_ORDER.forEach((variant) => {
+ const group = createElement("div", "progress-param-grid");
+ if (group) {
+ group.setAttribute("data-progress-param-group", variant);
+ group.hidden = ui.variant !== variant;
+ }
+ PROGRESS_PARAM_DEFS[variant].forEach((definition) => {
+ const value = ui[variant][definition.key];
+ const field = createElement("label", "progress-param");
+ const head = createElement("span", "progress-param-head");
+ appendChild(head, createElement("span", "", definition.label));
+ appendChild(head, createElement("output", "", formatProgressParamValue(definition, value)));
+ const input = createElement("input");
+ if (input) {
+ input.setAttribute("type", "range");
+ input.setAttribute("min", String(definition.min));
+ input.setAttribute("max", String(definition.max));
+ input.setAttribute("step", String(definition.step));
+ input.setAttribute("data-progress-param", definition.key);
+ input.setAttribute("data-progress-param-variant", variant);
+ input.value = String(value);
+ input.addEventListener("input", () => setProgressParam(variant, definition.key, input.value));
+ }
+ appendChild(field, head);
+ appendChild(field, input);
+ appendChild(group, field);
+ });
+ appendChild(panel, group);
+ });
+ }
+
+ function renderDebugSessionInfo(state) {
+ const container = byId("debug-session-info");
+ if (!container) {
+ return;
+ }
+ clearElement(container);
+ const fields = [
+ ["serverUrl", "Server URL", state.serverUrl || ""],
+ ["cwd", "CWD", state.cwd || ""],
+ ["contextId", "Context ID", state.contextId || "未获取"],
+ ["pipelineTaskId", "Pipeline Task", state.pipelineTaskId || "未获取"],
+ ["activeTaskId", "Active Task", state.activeTaskId || "未获取"],
+ ["lastSequence", "Last Sequence", String(state.lastSequence || 0)],
+ ["status", "Status", state.status || "idle"],
+ ["handoff", "Normal Handoff", state.normalHandoffReady ? "是" : "否"],
+ ["logs", "Logs", "默认 ~/.iac-code/logs,或 IAC_CODE_CONFIG_DIR/logs"],
+ ];
+ fields.forEach(([key, label, value]) => {
+ const row = createElement("div", "debug-session-field");
+ if (row) {
+ row.setAttribute("data-debug-session-field", key);
+ }
+ appendChild(row, createElement("span", "", label));
+ appendChild(row, createElement("code", "", value));
+ appendChild(container, row);
+ });
+ }
+
+ function renderDebug() {
+ const output = byId("debug-output") || query("#debug-drawer pre");
+ const state = ensureState();
+ renderDebugSessionInfo(state);
+ if (!output) {
+ return;
+ }
+ output.textContent = JSON.stringify(state.diagnostics || {}, null, 2);
+ }
+
+ function renderAll() {
+ renderStatus();
+ renderSteps();
+ renderComposerProgress();
+ renderPlans();
+ renderProgressDebugPanel();
+ renderDebug();
+ }
+
+ function diagnosticBucket(kind) {
+ if (kind === "sse") {
+ return "sse";
+ }
+ if (kind === "snapshot" || kind === "state") {
+ return "snapshots";
+ }
+ return "requests";
+ }
+
+ function appendDiagnostic(kind, value) {
+ const state = ensureState();
+ const diagnostics = state.diagnostics || { requests: [], sse: [], snapshots: [] };
+ const bucket = diagnosticBucket(kind);
+ const nextValue = clonePlainData({
+ at: new Date().toISOString(),
+ kind,
+ value,
+ });
+ diagnostics[bucket] = Array.isArray(diagnostics[bucket]) ? diagnostics[bucket] : [];
+ diagnostics[bucket].push(nextValue);
+ diagnostics[bucket] = diagnostics[bucket].slice(-40);
+ state.diagnostics = diagnostics;
+ renderDebug();
+ }
+
+ function showStatus(message, kind) {
+ const alert = byId("status-alert");
+ if (!alert) {
+ return;
+ }
+ if (!message) {
+ alert.hidden = true;
+ alert.textContent = "";
+ alert.removeAttribute("data-kind");
+ return;
+ }
+ alert.hidden = false;
+ alert.textContent = message;
+ alert.setAttribute("data-kind", kind || "info");
+ }
+
+ function ensureFetchAvailable() {
+ if (typeof fetch === "function") {
+ return true;
+ }
+ appendDiagnostic("error", { error: "fetch is not available" });
+ showStatus("当前环境不支持 fetch,无法连接 A2A 服务。", "error");
+ return false;
+ }
+
+ function queryString(params) {
+ if (typeof URLSearchParams === "function") {
+ const search = new URLSearchParams();
+ Object.keys(params).forEach((key) => {
+ search.set(key, params[key] === undefined || params[key] === null ? "" : String(params[key]));
+ });
+ return search.toString();
+ }
+ return Object.keys(params)
+ .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key] || "")}`)
+ .join("&");
+ }
+
+ async function readJsonResponse(response) {
+ const text = await response.text();
+ if (!text) {
+ return null;
+ }
+ try {
+ return JSON.parse(text);
+ } catch (error) {
+ return { ok: false, error: String(error), text };
+ }
+ }
+
+ function errorMessage(error) {
+ return error && error.message ? error.message : String(error);
+ }
+
+ function activeTaskIdFromPayload(payload) {
+ const envelope = extractPipelineEnvelope(payload);
+ const envelopeTaskId = taskIdOf(envelope || {});
+ if (envelopeTaskId) {
+ return envelopeTaskId;
+ }
+ if (payload && payload.result && typeof payload.result === "object") {
+ return payload.result.taskId || payload.result.task_id || payload.result.id || "";
+ }
+ if (payload && payload.task && typeof payload.task === "object") {
+ return payload.task.taskId || payload.task.task_id || payload.task.id || "";
+ }
+ return taskIdOf(payload || {}) || "";
+ }
+
+ function isWaitingForInputPayload(payload, state) {
+ const envelope = extractPipelineEnvelope(payload);
+ return (
+ Boolean(state && state.pendingInput) ||
+ (state && state.status === "waiting_input") ||
+ eventTypeOf(envelope || {}) === "input_required" ||
+ normalizeStatus((envelope && envelope.status) || "") === "waiting_input"
+ );
+ }
+
+ function waitForNextPaint() {
+ return new Promise((resolve) => {
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(() => resolve());
+ return;
+ }
+ if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
+ window.setTimeout(resolve, 16);
+ return;
+ }
+ if (typeof setTimeout === "function") {
+ setTimeout(resolve, 0);
+ return;
+ }
+ resolve();
+ });
+ }
+
+ function reduceControllerPayload(payload) {
+ const currentState = ensureState();
+ const nextState = reducePipelinePayload(currentState, payload);
+ const activeTaskId = activeTaskIdFromPayload(payload);
+ if (!nextState.normalHandoffReady && activeTaskId) {
+ nextState.activeTaskId = activeTaskId;
+ }
+ controller.state = nextState;
+ renderAll();
+ return nextState;
+ }
+
+ function handleSseBlock(block) {
+ const dataLines = String(block || "")
+ .split("\n")
+ .filter((line) => line.startsWith("data:"))
+ .map((line) => line.slice(5).trimStart());
+ if (dataLines.length === 0) {
+ return false;
+ }
+ const data = dataLines.join("\n").trim();
+ if (!data || data === "[DONE]") {
+ return false;
+ }
+ let payload;
+ try {
+ payload = JSON.parse(data);
+ } catch (error) {
+ appendDiagnostic("sse", { error: String(error), data });
+ showStatus("收到无法解析的 SSE 数据,详情见调试信息。", "error");
+ return false;
+ }
+ appendDiagnostic("sse", payload);
+ if (payload && payload.ok === false) {
+ throw new Error(payload.error || payload.message || "SSE stream reported an error");
+ }
+ const nextState = reduceControllerPayload(payload);
+ return isWaitingForInputPayload(payload, nextState);
+ }
+
+ async function consumeSseResponse(response) {
+ if (!response.ok) {
+ const errorText = typeof response.text === "function" ? await response.text() : "";
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
+ }
+ if (!response.body || typeof response.body.getReader !== "function") {
+ const text = typeof response.text === "function" ? await response.text() : "";
+ const blocks = text
+ .replace(/\r\n/g, "\n")
+ .split("\n\n")
+ .filter((block) => block.trim());
+ for (const block of blocks) {
+ const shouldStop = handleSseBlock(block);
+ await waitForNextPaint();
+ if (shouldStop) {
+ break;
+ }
+ }
+ return;
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let shouldStop = false;
+ while (!shouldStop) {
+ const { value, done } = await reader.read();
+ if (done) {
+ buffer += decoder.decode();
+ break;
+ }
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
+ let boundary = buffer.indexOf("\n\n");
+ while (boundary >= 0) {
+ const block = buffer.slice(0, boundary);
+ buffer = buffer.slice(boundary + 2);
+ shouldStop = handleSseBlock(block);
+ await waitForNextPaint();
+ if (shouldStop) {
+ break;
+ }
+ boundary = buffer.indexOf("\n\n");
+ }
+ }
+ if (!shouldStop && buffer.trim()) {
+ handleSseBlock(buffer);
+ await waitForNextPaint();
+ }
+ if (shouldStop && typeof reader.cancel === "function") {
+ await reader.cancel();
+ }
+ }
+
+ async function sendComposerMessage() {
+ if (!ensureFetchAvailable()) {
+ return;
+ }
+ const state = syncStateFromConnectionControls();
+ const composer = byId("composer-input");
+ const typedPrompt = composer && "value" in composer ? String(composer.value || "").trim() : "";
+ const prompt = typedPrompt || promptForSelectedCandidate(state);
+ if (!prompt) {
+ showStatus("请输入需求,或先选择一个方案。", "error");
+ return;
+ }
+ state.userMessages = Array.isArray(state.userMessages) ? state.userMessages : [];
+ const userMessageId = `user-${Date.now()}-${state.userMessages.length}`;
+ state.userMessages.push({
+ id: userMessageId,
+ text: prompt,
+ placement: userMessagePlacementForState(state),
+ });
+ if (state.normalHandoffReady) {
+ state.pendingNormalUserMessageId = userMessageId;
+ }
+ if (composer && "value" in composer && typedPrompt) {
+ composer.value = "";
+ }
+ renderAll();
+ const payload = buildStreamPayload(state, prompt);
+ appendDiagnostic("request", { method: "POST", path: "/api/message/stream", payload });
+ showStatus("正在发送消息并接收 pipeline 事件...", "info");
+ try {
+ const response = await fetch("/api/message/stream", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ await consumeSseResponse(response);
+ showStatus(ensureState().pendingInput ? "请选择或补充输入后继续。" : "消息已发送,状态已更新。", "info");
+ } catch (error) {
+ const message = errorMessage(error);
+ appendDiagnostic("error", { action: "send", error: message });
+ showStatus(`消息发送失败:${message}`, "error");
+ }
+ }
+
+ async function healthCheck() {
+ if (!ensureFetchAvailable()) {
+ return;
+ }
+ const state = syncStateFromConnectionControls();
+ const path = `/api/health?${queryString({ serverUrl: state.serverUrl })}`;
+ appendDiagnostic("request", { method: "GET", path });
+ try {
+ const response = await fetch(path);
+ const body = await readJsonResponse(response);
+ appendDiagnostic("request", { method: "GET", path, status: response.status, body });
+ showStatus(response.ok ? "连接检查完成。" : `连接检查失败:HTTP ${response.status}`, response.ok ? "info" : "error");
+ } catch (error) {
+ const message = errorMessage(error);
+ appendDiagnostic("error", { action: "health", error: message });
+ showStatus(`连接检查失败:${message}`, "error");
+ }
+ }
+
+ async function fetchState() {
+ if (!ensureFetchAvailable()) {
+ return;
+ }
+ const state = syncStateFromConnectionControls();
+ const taskId = state.activeTaskId || state.pipelineTaskId || "";
+ const path = `/api/pipeline/state?${queryString({
+ serverUrl: state.serverUrl,
+ contextId: state.contextId || "",
+ taskId,
+ afterSequence: state.lastSequence || 0,
+ })}`;
+ appendDiagnostic("request", { method: "GET", path });
+ try {
+ const response = await fetch(path);
+ const body = await readJsonResponse(response);
+ appendDiagnostic("state", { status: response.status, body });
+ if (body) {
+ reduceControllerPayload(body);
+ }
+ showStatus(response.ok ? "状态已同步。" : `同步状态失败:HTTP ${response.status}`, response.ok ? "info" : "error");
+ } catch (error) {
+ const message = errorMessage(error);
+ appendDiagnostic("error", { action: "fetchState", error: message });
+ showStatus(`同步状态失败:${message}`, "error");
+ }
+ }
+
+ async function cancelTask() {
+ if (!ensureFetchAvailable()) {
+ return;
+ }
+ const state = syncStateFromConnectionControls();
+ const payload = {
+ serverUrl: state.serverUrl || "",
+ contextId: state.contextId || "",
+ taskId: state.activeTaskId || state.pipelineTaskId || "",
+ };
+ appendDiagnostic("request", { method: "POST", path: "/api/task/cancel", payload });
+ try {
+ const response = await fetch("/api/task/cancel", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ const body = await readJsonResponse(response);
+ appendDiagnostic("request", { method: "POST", path: "/api/task/cancel", status: response.status, body });
+ showStatus(response.ok ? "取消请求已发送。" : `取消任务失败:HTTP ${response.status}`, response.ok ? "info" : "error");
+ } catch (error) {
+ const message = errorMessage(error);
+ appendDiagnostic("error", { action: "cancel", error: message });
+ showStatus(`取消任务失败:${message}`, "error");
+ }
+ }
+
+ function bindEvents() {
+ if (controller.bound) {
+ return;
+ }
+ const serverInput = byId("server-url");
+ const cwdInput = byId("cwd");
+ const sendButton = byId("send-button");
+ const composer = byId("composer-input");
+ const healthButton = byId("health-button");
+ const fetchStateButton = byId("fetch-state-button");
+ const cancelButton = byId("cancel-button");
+ const debugDrawer = byId("debug-drawer");
+ const addListener = (element, eventName, handler) => {
+ if (element && typeof element.addEventListener === "function") {
+ element.addEventListener(eventName, handler);
+ }
+ };
+
+ addListener(serverInput, "input", syncStateFromConnectionControls);
+ addListener(cwdInput, "input", syncStateFromConnectionControls);
+ addListener(sendButton, "click", sendComposerMessage);
+ addListener(healthButton, "click", healthCheck);
+ addListener(fetchStateButton, "click", fetchState);
+ addListener(cancelButton, "click", cancelTask);
+ addListener(debugDrawer, "toggle", renderAll);
+ addListener(composer, "keydown", (event) => {
+ if ((event.key === "Enter" && !event.shiftKey) || (event.key === "Enter" && (event.metaKey || event.ctrlKey))) {
+ event.preventDefault();
+ sendComposerMessage();
+ }
+ });
+ controller.bound = Boolean(
+ serverInput || cwdInput || sendButton || composer || healthButton || fetchStateButton || cancelButton || debugDrawer
+ );
+ }
+
+ function loadDemoCandidates() {
+ let state = ensureState();
+ state = upsertCandidate(state, {
+ name: "ECS 经典网络方案",
+ candidateIndex: 0,
+ summary: "使用 VPC、ECS 与弹性公网 IP 搭建轻量 Web 服务,保留后续扩容空间。",
+ totalMonthlyCost: "¥33.89/月",
+ costItems: [
+ { name: "ECS", spec: "1vCPU/1GiB", monthly_cost: "¥33.89/月" },
+ { name: "EIP", spec: "按量公网带宽", monthly_cost: "按实际流量" },
+ ],
+ });
+ state = upsertCandidate(state, {
+ name: "轻量应用服务器一体化方案",
+ candidateIndex: 1,
+ summary: "面向演示、测试与低流量站点,预置应用环境并降低运维复杂度。",
+ totalMonthlyCost: "¥0/月",
+ costItems: [
+ { name: "轻量应用服务器", spec: "试用规格", monthly_cost: "¥0/月" },
+ { name: "基础监控", spec: "默认启用", monthly_cost: "¥0/月" },
+ ],
+ });
+ state.steps.intent_parsing.status = "completed";
+ state.steps.architecture_planning.status = "completed";
+ state.steps.evaluate_candidates.status = "completed";
+ state.steps.confirm_and_select.status = "waiting_input";
+ state.status = "waiting_input";
+ state.pipelineStarted = true;
+ state.pendingInput = {
+ kind: "candidate_selection",
+ prompt: "请选择推荐方案",
+ options: [
+ { id: "0", label: "ECS 经典网络方案" },
+ { id: "1", label: "轻量应用服务器一体化方案" },
+ ],
+ };
+ controller.state = state;
+ renderAll();
+ return state;
+ }
+
+ function init() {
+ ensureState();
+ syncConnectionControlsFromState();
+ syncStateFromConnectionControls();
+ bindEvents();
+ renderAll();
+ return controller.state;
+ }
+
+ window.SellingConsoleController = {
+ init,
+ renderSteps,
+ renderPlans,
+ sendComposerMessage,
+ healthCheck,
+ fetchState,
+ cancelTask,
+ appendDiagnostic,
+ renderDebug,
+ };
+ window.SellingConsoleDebug = {
+ loadDemoCandidates,
+ state: () => ensureState(),
+ render: renderAll,
+ };
+
+ if (hasDocument()) {
+ if (document.readyState === "loading" && typeof document.addEventListener === "function") {
+ document.addEventListener("DOMContentLoaded", init, { once: true });
+ } else {
+ init();
+ }
+ }
+})();
diff --git a/scripts/a2a/selling_console_web/design/selling-pipeline-progress-options.html b/scripts/a2a/selling_console_web/design/selling-pipeline-progress-options.html
new file mode 100644
index 00000000..78f5d72e
--- /dev/null
+++ b/scripts/a2a/selling_console_web/design/selling-pipeline-progress-options.html
@@ -0,0 +1,1655 @@
+
+
+
+
+
+
进度控件方案 v61 B 标签对齐
+
+
+
+
+
+
+
v61:B 标签对齐版
+
B1 下方 Step 标题改为跟随节点同一组位置对齐,并移除无信息价值的底部说明文字;D 保持 v60。
+
+
B2 已移除
+
+
+
+
+
+
+
+
需求理解已完成:识别业务场景、规模和预算。
+
架构规划已完成:拆分网络、计算、访问入口。
+
方案评估已完成:比较成本、复杂度和扩展性。
+
方案选择进行中:正在生成方案卡片并等待确认。
+
确认部署未开始:选定方案后进入部署确认。
+
+
请描述业务场景、访问规模、预算范围或已有资源约束
+
+
+
演示 Step 方案选择
+
+ 1
+ 2
+ 3
+ 4
+ 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 需求理解
+ 架构规划
+ 方案评估
+ 方案选择
+ 确认部署
+
+
+
请描述业务场景、访问规模、预算范围或已有资源约束
+
+
+
+
演示 Step 方案选择
+
+ 1
+ 2
+ 3
+ 4
+ 5
+
+
+
+ X 28%
+
+
+
+ Y 49%
+
+
+
+ T1 140ms
+
+
+
+ T2 540ms
+
+
+
+ 最大振幅 9
+
+
+
+ 停顿时间 510ms
+
+
+
+
+
+
+
+
+
B2. 蓝色脉冲波形 双扫光
+
紧凑双峰
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
需求理解已完成:需求已归纳。
+
架构规划已完成:资源关系已确定。
+
方案评估蓝色线路从上一步进入当前步骤。
+
方案选择两道白色扫光形成更强脉冲感。
+
确认部署进入下一步前逐步收束。
+
+
+
请描述业务场景、访问规模、预算范围或已有资源约束
+
+
+
+
+
+
+
+
+
+
需求理解 已完成:需求已归纳。
+
架构规划 已完成:资源关系已确定。
+
方案评估 已完成:已比较候选方案。
+
方案选择 进行中:正在生成方案。
+
确认部署 未开始:等待方案选择。
+
+
+
请描述业务场景、访问规模、预算范围或已有资源约束
+
+
+
演示 Step 方案选择
+
+ 1
+ 2
+ 3
+ 4
+ 5
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
AI 购买助手
+
售卖 Pipeline
+
+
等待输入
+
+
+
+
+
+
+
+
+
内容由 AI 生成,方案与价格仅供参考
+
+
+
+
+
+
+
+
+
+ 轻量 Web 应用方案
+ 适合中小规模业务上线,包含 VPC、ECS、SLB 与基础监控。
+ ¥ 238.00 / 月
+
+
+
地域
+ 华东 1(杭州)
+
+
+
弹性
+ 支持后续扩容
+
+
+
+
+
+ 高可用标准方案
+ 面向生产流量,加入多可用区部署、日志服务与云安全中心。
+ ¥ 486.00 / 月
+
+
+
地域
+ 华东 1(杭州)
+
+
+
容灾
+ 跨可用区
+
+
+
+
+
+
+ 调试面板
+
+
+
+
+
+ Pipeline Diagnostics
+ 等待 pipeline 事件...
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/a2a/selling_console_web/styles.css b/scripts/a2a/selling_console_web/styles.css
new file mode 100644
index 00000000..e4198e90
--- /dev/null
+++ b/scripts/a2a/selling_console_web/styles.css
@@ -0,0 +1,2371 @@
+:root {
+ color-scheme: light;
+ --aliyun-orange: #ff6a00;
+ --aliyun-blue: #1677ff;
+ --ink: #1f2937;
+ --muted: #667085;
+ --subtle: #98a2b3;
+ --line: #d8e3f0;
+ --line-strong: #b8c7d9;
+ --surface: #f4f7fb;
+ --surface-blue: #f2f8ff;
+ --white: #ffffff;
+ --success: #0f9f6e;
+ --blue: var(--aliyun-blue);
+ --green: #13a36f;
+ --shadow: 0 8px 22px rgba(31, 41, 55, 0.08);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ height: 100%;
+ overflow-x: hidden;
+}
+
+body {
+ min-height: 100%;
+ margin: 0;
+ background: var(--surface);
+ color: var(--ink);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+ font-size: 14px;
+ letter-spacing: 0;
+ overflow-x: hidden;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+summary:focus-visible {
+ outline: 2px solid var(--aliyun-blue);
+ outline-offset: 2px;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ height: 64px;
+ max-width: 100vw;
+ padding: 0 24px;
+ border-bottom: 1px solid var(--line);
+ background: var(--white);
+ box-shadow: 0 1px 2px rgba(31, 41, 55, 0.04);
+}
+
+.icon-button {
+ width: 36px;
+ height: 36px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--white);
+ color: var(--ink);
+}
+
+.brand {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ min-width: 200px;
+ white-space: nowrap;
+}
+
+.brand strong {
+ color: var(--aliyun-orange);
+ font-size: 22px;
+ font-weight: 700;
+}
+
+.brand span {
+ color: var(--muted);
+ font-weight: 600;
+}
+
+.topbar-nav,
+.topbar-links,
+.user-summary {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ white-space: nowrap;
+}
+
+.topbar-nav a,
+.topbar-links a {
+ color: var(--muted);
+}
+
+.topbar-nav a:hover,
+.topbar-links a:hover {
+ color: var(--aliyun-blue);
+}
+
+.nav-pill {
+ height: 32px;
+ border: 1px solid #bed7f5;
+ border-radius: 16px;
+ background: var(--surface-blue);
+ color: #1155a3;
+ padding: 0 14px;
+}
+
+.topbar-search {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+ min-width: 0;
+ max-width: 520px;
+ height: 38px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fbfdff;
+ padding: 0 12px;
+ color: var(--subtle);
+}
+
+.topbar-search input {
+ width: 100%;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--ink);
+}
+
+.topbar-search:focus-within {
+ border-color: var(--aliyun-blue);
+ box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
+}
+
+.user-summary {
+ margin-left: auto;
+ color: var(--muted);
+}
+
+.avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ background: #edf4ff;
+ color: var(--aliyun-blue);
+ font-weight: 700;
+}
+
+.console-shell {
+ display: grid;
+ grid-template-columns: minmax(280px, 400px) minmax(0, 1fr) 56px;
+ gap: 16px;
+ width: 100%;
+ max-width: 100vw;
+ height: calc(100vh - 64px);
+ padding: 16px;
+ overflow-x: hidden;
+}
+
+.utility-rail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+
+.workflow-panel,
+.plan-area {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--white);
+ box-shadow: var(--shadow);
+}
+
+.workflow-panel {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 96px);
+ min-height: 0;
+ overflow: hidden;
+}
+
+.panel-heading {
+ display: none;
+}
+
+.plan-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ border-bottom: 1px solid var(--line);
+ padding: 20px 22px;
+}
+
+.plan-header {
+ flex-direction: column;
+ align-items: stretch;
+ padding: 18px 20px;
+}
+
+.eyebrow {
+ margin: 0 0 6px;
+ color: var(--aliyun-blue);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+h1,
+h2,
+p {
+ margin-top: 0;
+}
+
+.panel-heading h1,
+.plan-header h1 {
+ margin-bottom: 0;
+ font-size: 22px;
+ line-height: 1.25;
+}
+
+.status-pill {
+ border: 1px solid #b7dfd0;
+ border-radius: 16px;
+ background: #eefaf5;
+ color: var(--success);
+ padding: 6px 12px;
+ font-weight: 700;
+}
+
+.status-alert {
+ display: none;
+ margin: 14px 20px 0;
+ border: 1px solid #9ec9fb;
+ border-radius: 8px;
+ background: var(--surface-blue);
+ color: #1155a3;
+ padding: 10px 12px;
+}
+
+.plan-card,
+.debug-output,
+.debug-drawer pre,
+.status-alert,
+.normal-handoff-message,
+.normal-turn,
+.normal-process,
+.step-card,
+.chat-bubble,
+.user-message-text,
+.connection-controls input {
+ overflow-wrap: anywhere;
+}
+
+.step-list {
+ display: grid;
+ align-content: start;
+ align-items: start;
+ flex: 1 1 auto;
+ gap: 5px;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 8px 14px;
+}
+
+.chat-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ min-width: 0;
+}
+
+.chat-message.system {
+ justify-content: flex-start;
+}
+
+.chat-message.user {
+ justify-content: flex-end;
+}
+
+.chat-message.user .chat-bubble {
+ order: 1;
+ max-width: 82%;
+}
+
+.chat-message.user .chat-avatar {
+ order: 2;
+}
+
+.chat-bubble {
+ min-width: 0;
+ max-width: 100%;
+}
+
+.chat-message.system .chat-bubble {
+ flex: 1 1 auto;
+}
+
+.chat-avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 24px;
+ height: 24px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0;
+}
+
+.chat-avatar.system {
+ background: linear-gradient(135deg, #ff6a00 0%, #1677ff 100%);
+ color: var(--white);
+}
+
+.chat-avatar.user {
+ border: 1px solid #cfe0f7;
+ background: #edf4ff;
+ color: var(--aliyun-blue);
+}
+
+.chat-message.system .step-card,
+.chat-message.system .normal-handoff-message {
+ width: 100%;
+}
+
+.user-message-text {
+ margin: 0;
+ border: 1px solid #cfe0f7;
+ border-radius: 8px;
+ background: #edf4ff;
+ color: var(--ink);
+ padding: 6px 9px;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.step-card {
+ display: grid;
+ grid-template-columns: 24px 1fr;
+ gap: 6px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fbfdff;
+ padding: 7px 8px;
+}
+
+.step-card.current {
+ border-color: #9ec9fb;
+ background: var(--surface-blue);
+}
+
+.step-card.completed {
+ align-items: center;
+ background: #fbfffd;
+ grid-template-columns: 24px 1fr;
+ gap: 6px;
+ padding: 6px 8px;
+}
+
+.step-card.failed,
+.step-card.error {
+ border-color: #f5b5ad;
+ background: #fff7f5;
+}
+
+.step-index {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: var(--white);
+ color: var(--aliyun-blue);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.step-card.completed .step-index {
+ width: 22px;
+ height: 22px;
+}
+
+.step-state-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border-radius: 999px;
+ color: var(--white);
+ font-size: 9px;
+ line-height: 1;
+}
+
+.step-state-icon.completed {
+ background: var(--success);
+}
+
+.step-state-icon.working {
+ background: var(--aliyun-blue);
+}
+
+.step-state-icon.waiting_input {
+ background: var(--aliyun-orange);
+}
+
+.step-state-icon.failed,
+.step-state-icon.error {
+ background: #d92d20;
+}
+
+.step-card h2 {
+ margin-bottom: 0;
+ font-size: 13px;
+ font-weight: 750;
+ line-height: 1.28;
+}
+
+.step-card-body {
+ min-width: 0;
+}
+
+.step-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ padding: 0;
+ text-align: left;
+}
+
+.step-toggle-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 999px;
+ color: var(--subtle);
+}
+
+.step-toggle-icon::before {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(45deg) translate(-1px, -1px);
+}
+
+.step-toggle-icon.expanded::before {
+ transform: rotate(225deg) translate(-1px, -1px);
+}
+
+.step-detail {
+ display: grid;
+ gap: 6px;
+ grid-column: 2;
+ min-width: 0;
+}
+
+.step-status {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.step-event-list {
+ display: grid;
+ gap: 5px;
+ max-height: 180px;
+ overflow-y: auto;
+ margin: 0;
+ padding: 0;
+ padding-right: 4px;
+ list-style: none;
+}
+
+.step-waiting-prompt {
+ margin: 0;
+ color: var(--ink);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.step-event-card,
+.step-result,
+.step-result-list {
+ margin-bottom: 0;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.step-event-card {
+ border-left: 2px solid #9ec9fb;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.72);
+ padding-left: 8px;
+}
+
+.step-event-card.tool_result,
+.step-event-card.tool_use {
+ border-left-color: var(--aliyun-blue);
+ background: rgba(22, 119, 255, 0.06);
+}
+
+.step-event-card.input_required {
+ border-left-color: var(--aliyun-orange);
+ background: rgba(255, 106, 0, 0.06);
+}
+
+.step-event-card.text_delta .step-event-title::after {
+ content: "";
+ display: inline-block;
+ width: 1px;
+ height: 1em;
+ margin-left: 2px;
+ background: currentColor;
+ transform: translateY(2px);
+ animation: typingCaret 0.9s steps(1, end) infinite;
+}
+
+.step-event-label {
+ display: inline-flex;
+ margin-bottom: 3px;
+ color: var(--aliyun-blue);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.step-event-title {
+ margin-bottom: 4px;
+ color: var(--ink);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.step-event-meta,
+.step-result-list {
+ display: grid;
+ gap: 4px;
+ margin: 0;
+}
+
+.step-event-meta div,
+.step-result-list div {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 4px;
+ min-width: 0;
+}
+
+.step-event-meta dt,
+.step-result-list dt {
+ color: var(--subtle);
+ font-weight: 700;
+}
+
+.step-event-meta dd,
+.step-result-list dd {
+ margin: 0;
+ color: var(--ink);
+ overflow-wrap: anywhere;
+}
+
+.step-candidate-progress-list {
+ display: grid;
+ gap: 6px;
+ margin: 0;
+}
+
+.step-candidate-progress {
+ display: grid;
+ gap: 3px;
+ border-left: 2px solid #9ec9fb;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.74);
+ padding: 8px 9px;
+}
+
+.step-candidate-progress-head {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ min-width: 0;
+}
+
+.step-candidate-progress strong {
+ color: var(--ink);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.step-candidate-progress span {
+ color: var(--muted);
+ font-size: 12px;
+ min-width: 0;
+}
+
+.step-candidate-progress-head span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.step-candidate-progress p {
+ margin: 0;
+ color: var(--ink);
+ font-size: 12px;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.step-result-options {
+ display: grid;
+ gap: 8px;
+}
+
+.step-result-option {
+ display: grid;
+ gap: 4px;
+ border: 1px solid #dbe6f5;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.72);
+ padding: 9px 10px;
+}
+
+.step-result-option strong {
+ color: var(--ink);
+ font-size: 12px;
+}
+
+.step-result-option span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.step-result-option .price {
+ color: var(--aliyun-orange);
+ font-weight: 800;
+}
+
+.step-process {
+ display: grid;
+ gap: 6px;
+ border-top: 1px solid #edf2f7;
+ padding-top: 8px;
+}
+
+.step-process-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 12px;
+ list-style: none;
+}
+
+.step-process-head::-webkit-details-marker {
+ display: none;
+}
+
+.step-process-head strong {
+ color: var(--aliyun-blue);
+ font-size: 12px;
+}
+
+.step-process-events {
+ margin-top: 6px;
+}
+
+.step-candidate-result-list {
+ display: grid;
+ gap: 8px;
+}
+
+.step-candidate-result {
+ display: grid;
+ gap: 6px;
+ border: 1px solid #dbe6f5;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.72);
+ padding: 8px 9px;
+}
+
+.step-candidate-result-head {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+
+.step-candidate-result-head strong {
+ color: var(--aliyun-blue);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.step-candidate-result-head span {
+ min-width: 0;
+ color: var(--ink);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.step-candidate-result-summary {
+ margin: 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.step-candidate-result-label {
+ color: var(--aliyun-blue);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.step-candidate-result-template {
+ color: var(--subtle);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.step-candidate-result-price {
+ color: var(--aliyun-orange);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.step-candidate-result-process {
+ display: grid;
+ gap: 6px;
+ border-top: 1px solid #edf2f7;
+ padding-top: 6px;
+}
+
+.step-candidate-result-process-body {
+ max-height: 180px;
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+.candidate-choice-list {
+ display: grid;
+ gap: 10px;
+}
+
+.pending-input-card {
+ display: grid;
+ gap: 10px;
+ border: 1px solid #ffd0a8;
+ border-radius: 8px;
+ background: #fffaf5;
+ padding: 12px;
+}
+
+.pending-input-card h2 {
+ margin: 0;
+ color: var(--aliyun-orange);
+ font-size: 13px;
+}
+
+.pending-input-prompt {
+ margin: 0;
+ color: var(--ink);
+ line-height: 1.55;
+}
+
+.pending-input-prompt p,
+.pending-input-option-description p {
+ margin: 0;
+}
+
+.pending-input-prompt ul,
+.pending-input-prompt ol,
+.pending-input-option-description ul,
+.pending-input-option-description ol {
+ display: grid;
+ gap: 2px;
+ margin: 4px 0 0;
+ padding-left: 18px;
+}
+
+.pending-input-prompt a,
+.pending-input-option-description a {
+ color: var(--aliyun-blue);
+ text-decoration: none;
+}
+
+.pending-input-prompt a:hover,
+.pending-input-option-description a:hover {
+ text-decoration: underline;
+}
+
+.pending-input-options {
+ display: grid;
+ gap: 8px;
+}
+
+.pending-input-option {
+ display: grid;
+ gap: 4px;
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--white);
+ color: var(--ink);
+ padding: 10px 12px;
+ text-align: left;
+ line-height: 1.45;
+}
+
+.pending-input-option:hover,
+.pending-input-option.selected {
+ border-color: #9ec9fb;
+ background: var(--surface-blue);
+}
+
+.pending-input-option span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.pending-input-option-description {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.template-popover-host {
+ position: relative;
+}
+
+.template-popover {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ left: 12px;
+ z-index: 30;
+ display: grid;
+ gap: 8px;
+ max-height: 260px;
+ overflow-y: auto;
+ pointer-events: auto;
+ visibility: hidden;
+ border: 1px solid #c7d7eb;
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.96);
+ box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22);
+ color: var(--white);
+ opacity: 0;
+ padding: 10px 12px;
+ transform: translateY(4px);
+ transition: opacity 140ms ease, transform 140ms ease, visibility 0ms linear 140ms;
+ transition-delay: 0ms, 0ms, 140ms;
+}
+
+.template-popover-host:hover .template-popover,
+.template-popover-host:focus-within .template-popover,
+.template-popover:hover {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition-delay: 500ms, 500ms, 500ms;
+}
+
+.template-popover-title {
+ color: #dbeafe;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.template-popover pre {
+ margin: 0;
+ color: #f8fafc;
+ font-size: 11px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.candidate-choice {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--white);
+ color: var(--ink);
+ padding: 12px 14px;
+ text-align: left;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+.candidate-choice:hover,
+.candidate-choice.selected {
+ border-color: #9ec9fb;
+ background: var(--surface-blue);
+}
+
+.candidate-subpipeline {
+ display: grid;
+ gap: 8px;
+ margin-top: 12px;
+ border-top: 1px solid #edf2f7;
+ padding-top: 12px;
+}
+
+.candidate-subpipeline-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 12px;
+ list-style: none;
+}
+
+.candidate-subpipeline-head::-webkit-details-marker {
+ display: none;
+}
+
+.candidate-subpipeline-head strong {
+ color: var(--aliyun-blue);
+ font-size: 12px;
+}
+
+.candidate-subpipeline-arrow {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border: 1px solid #c7d7eb;
+ border-radius: 999px;
+ color: var(--subtle);
+ flex: 0 0 auto;
+}
+
+.candidate-subpipeline-arrow::before {
+ content: "";
+ width: 5px;
+ height: 5px;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(45deg) translate(-1px, -1px);
+}
+
+.candidate-subpipeline[open] .candidate-subpipeline-arrow::before {
+ transform: rotate(225deg) translate(-1px, -1px);
+}
+
+.candidate-subpipeline-body {
+ max-height: 180px;
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+.candidate-substeps {
+ display: grid;
+ gap: 6px;
+}
+
+.candidate-substep {
+ display: grid;
+ gap: 6px;
+ border-radius: 7px;
+ background: rgba(255, 255, 255, 0.62);
+ padding: 7px 8px;
+}
+
+.candidate-substep-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 12px;
+ list-style: none;
+}
+
+.candidate-substep-head::-webkit-details-marker {
+ display: none;
+}
+
+.candidate-substep-head strong {
+ color: var(--ink);
+ font-size: 12px;
+}
+
+.candidate-subpipeline-events {
+ display: grid;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.candidate-subpipeline-event {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 8px;
+ align-items: start;
+ border-left: 2px solid #9ec9fb;
+ padding-left: 8px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.candidate-subpipeline-event p {
+ margin: 0;
+ color: var(--ink);
+ overflow-wrap: anywhere;
+}
+
+.candidate-subpipeline-label {
+ color: var(--subtle);
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.composer {
+ flex: 0 0 auto;
+ margin-top: auto;
+ padding: 6px 14px 10px;
+}
+
+.normal-handoff-message {
+ display: grid;
+ gap: 4px;
+ border: 1px solid #b7ead4;
+ border-radius: 8px;
+ background: #f2fbf7;
+ color: #12734d;
+ padding: 9px 10px;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.normal-handoff-message strong {
+ font-weight: 800;
+}
+
+.normal-handoff-message p {
+ margin: 0;
+ color: #12734d;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.normal-turn {
+ display: grid;
+ gap: 6px;
+ width: 100%;
+ border: 1px solid #cfe0f7;
+ border-radius: 8px;
+ background: #fbfdff;
+ padding: 8px 10px;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.normal-turn.working {
+ border-color: #9ec9fb;
+ background: var(--surface-blue);
+}
+
+.normal-turn.failed,
+.normal-turn.error {
+ border-color: #f5b5ad;
+ background: #fff7f5;
+}
+
+.normal-process {
+ border: 0;
+ border-bottom: 1px solid #e3ebf6;
+ padding-bottom: 5px;
+}
+
+.normal-process-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--aliyun-blue);
+ font-size: 12px;
+ font-weight: 750;
+ line-height: 1.3;
+ list-style: none;
+ cursor: pointer;
+}
+
+.normal-process-summary::-webkit-details-marker {
+ display: none;
+}
+
+.normal-process-summary::after {
+ content: "⌄";
+ color: var(--subtle);
+ font-size: 12px;
+ transition: transform 0.16s ease;
+}
+
+.normal-process[open] .normal-process-summary::after {
+ transform: rotate(180deg);
+}
+
+.normal-process-count {
+ margin-left: auto;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 650;
+}
+
+.normal-process-events {
+ display: grid;
+ gap: 5px;
+ max-height: 180px;
+ margin: 6px 0 0;
+ padding: 0;
+ overflow-y: auto;
+ list-style: none;
+}
+
+.normal-process-event {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 7px;
+ align-items: start;
+ border-left: 2px solid #9ec9fb;
+ padding-left: 7px;
+ color: var(--muted);
+}
+
+.normal-process-event-label {
+ color: var(--subtle);
+ font-weight: 750;
+ white-space: nowrap;
+}
+
+.normal-process-event p {
+ margin: 0;
+ color: var(--ink);
+}
+
+.normal-answer {
+ margin: 0;
+ color: var(--ink);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.composer-progress {
+ margin-bottom: 8px;
+ min-width: 0;
+}
+
+.composer-progress[hidden] {
+ display: none;
+}
+
+.composer-progress:not([hidden]) {
+ position: relative;
+ margin-bottom: 8px;
+ border-bottom: 1px solid var(--line);
+ padding-bottom: 8px;
+}
+
+.progress-shell {
+ display: block;
+}
+
+.composer-progress .tip {
+ position: absolute;
+ z-index: 50;
+ left: 50%;
+ top: calc(100% + 8px);
+ width: max-content;
+ max-width: 196px;
+ transform: translateX(-50%) translateY(-3px);
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: #111827;
+ color: #f8fafc;
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1.45;
+ white-space: normal;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s ease, transform 0.15s ease;
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.22);
+}
+
+.composer-progress .step:hover .tip,
+.composer-progress .signal-node:hover .tip,
+.composer-progress .fusion-step:hover .tip {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+}
+
+.composer-progress.chevrons {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ height: 32px;
+ isolation: isolate;
+}
+
+.chevrons .step {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ margin-left: -6px;
+ padding: 0 10px 0 14px;
+ clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 0 100%, 8px 50%);
+ background: #edf2f7;
+ color: #405066;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
+.chevrons .step:first-child {
+ margin-left: 0;
+ border-radius: 7px 0 0 7px;
+ clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 0 100%);
+}
+
+.chevrons .step:last-child {
+ border-radius: 0 7px 7px 0;
+}
+
+.chevrons .done {
+ background: #e9f7f1;
+ color: #14704d;
+}
+
+.chevrons .active {
+ z-index: 2;
+ background: linear-gradient(90deg, #1677ff, #28a4ff);
+ color: #fff;
+ box-shadow: 0 4px 11px rgba(22, 119, 255, 0.2);
+}
+
+.chevrons .active::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 110deg,
+ transparent 0%,
+ transparent 38%,
+ rgba(255, 255, 255, 0.5) 50%,
+ transparent 62%,
+ transparent 100%
+ );
+ animation: sweep var(--progress-a-sweep-ms, 1800ms) linear infinite;
+}
+
+.signal-circuit {
+ position: relative;
+ height: 50px;
+ padding: 2px 8px 0;
+ overflow: hidden;
+ --absorb-duration: 510ms;
+}
+
+.signal-svg {
+ position: absolute;
+ inset: 0 8px auto 8px;
+ width: calc(100% - 16px);
+ height: 36px;
+ overflow: visible;
+}
+
+.signal-active-base,
+.signal-moving-wave,
+.signal-rail,
+.signal-done {
+ fill: none;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ vector-effect: non-scaling-stroke;
+}
+
+.signal-rail {
+ stroke: #d5dfec;
+ stroke-width: 2px;
+}
+
+.signal-done {
+ stroke: var(--green);
+ stroke-width: 2px;
+}
+
+.signal-active-base {
+ stroke-width: 1.35px;
+}
+
+.signal-active-in {
+ stroke: rgba(22, 119, 255, 0.46);
+}
+
+.signal-active-out {
+ stroke: rgba(143, 155, 174, 0.6);
+}
+
+.signal-moving-wave {
+ stroke: var(--blue);
+ stroke-width: 1.2px;
+ opacity: 0.98;
+}
+
+.signal-node {
+ position: absolute;
+ top: 18px;
+ width: 12px;
+ height: 12px;
+ border: 2px solid #7cc8a6;
+ border-radius: 50%;
+ background: #fff;
+ transform: translate(-50%, -50%);
+ z-index: 2;
+}
+
+.signal-node.active {
+ width: 15px;
+ height: 15px;
+ border-color: var(--blue);
+ box-shadow: 0 0 0 3px #fff;
+ overflow: hidden;
+}
+
+.signal-absorb-halo {
+ position: absolute;
+ top: 18px;
+ width: 20px;
+ height: 20px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ z-index: 1;
+}
+
+.signal-absorb-halo::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 999px;
+ opacity: 0;
+ transform: scale(0.78);
+ transform-origin: center;
+ background: radial-gradient(circle, rgba(64, 217, 255, 0.34) 0 34%, rgba(22, 119, 255, 0.16) 52%, rgba(22, 119, 255, 0) 76%);
+ filter: blur(0.3px);
+}
+
+.signal-node-core {
+ position: absolute;
+ inset: 1.5px;
+ border-radius: 999px;
+ background: radial-gradient(
+ circle at 50% 48%,
+ rgba(255, 255, 255, 1) 0 12%,
+ rgba(110, 230, 255, 0.98) 30%,
+ rgba(22, 119, 255, 0.92) 68%,
+ rgba(22, 119, 255, 0.16) 100%
+ );
+ opacity: 0;
+ transform: scale(0.08);
+ transform-origin: center;
+ box-shadow: inset 0 0 3px rgba(255, 255, 255, 0.7), 0 0 10px rgba(22, 119, 255, 0.56);
+}
+
+.signal-node-charge {
+ position: absolute;
+ inset: 1.5px;
+ border-radius: 999px;
+ opacity: 0;
+ transform: scale(0.76) rotate(-90deg);
+ transform-origin: center;
+ background:
+ conic-gradient(
+ from 210deg,
+ rgba(22, 119, 255, 0) 0deg,
+ rgba(22, 119, 255, 0.22) 42deg,
+ rgba(64, 217, 255, 0.95) 86deg,
+ rgba(255, 255, 255, 0.98) 126deg,
+ rgba(22, 119, 255, 0.92) 178deg,
+ rgba(22, 119, 255, 0.16) 232deg,
+ rgba(22, 119, 255, 0) 300deg,
+ rgba(22, 119, 255, 0) 360deg
+ );
+ box-shadow: inset 0 0 5px rgba(255, 255, 255, 0.45), 0 0 7px rgba(22, 119, 255, 0.44);
+ filter: saturate(1.08);
+}
+
+.signal-circuit.absorbing .signal-absorb-halo::before {
+ animation: signalAbsorbGlow var(--absorb-duration) ease-out both;
+}
+
+.signal-circuit.absorbing .signal-node.active .signal-node-charge {
+ animation: signalNodeChargeRing var(--absorb-duration) cubic-bezier(0.18, 0.78, 0.24, 1) both;
+}
+
+.signal-circuit.absorbing .signal-node.active .signal-node-core {
+ animation: signalNodeInnerAbsorb var(--absorb-duration) ease-out both;
+}
+
+.signal-circuit.absorbing .signal-node.active {
+ animation: signalAbsorbCore var(--absorb-duration) ease-out both;
+}
+
+.signal-node.next,
+.signal-node.pending {
+ border-color: #b9c5d4;
+}
+
+.signal-labels {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 32px;
+ color: #42526a;
+ font-size: 9px;
+ font-weight: 650;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.signal-labels span {
+ position: absolute;
+ top: 0;
+ transform: translateX(-50%);
+}
+
+.signal-labels .active {
+ color: var(--blue);
+ font-weight: 760;
+}
+
+.fusion-label {
+ --fusion-green-end: 69.6%;
+ --fusion-blue-start: 69.6%;
+ --fusion-blue-end: 83.2%;
+ --fusion-sweep-duration: 1800ms;
+ position: relative;
+ display: grid;
+ grid-template-columns: 1fr;
+ align-items: center;
+ min-height: 36px;
+ padding: 5px 10px;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ background:
+ linear-gradient(#fff, #fff) padding-box,
+ linear-gradient(
+ 90deg,
+ #13a36f 0 var(--fusion-green-end),
+ #1677ff var(--fusion-blue-start) var(--fusion-blue-end),
+ #dce5f2 var(--fusion-blue-end) 100%
+ ) border-box;
+ box-shadow: inset 0 1px 0 rgba(22, 119, 255, 0.06);
+}
+
+.fusion-label::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: -1px;
+ height: 2px;
+ border-radius: 999px;
+ background:
+ linear-gradient(
+ 90deg,
+ transparent 0%,
+ rgba(255, 255, 255, 0) 30%,
+ rgba(125, 185, 255, 0.34) 42%,
+ rgba(255, 255, 255, 0.96) 50%,
+ rgba(125, 185, 255, 0.32) 58%,
+ rgba(255, 255, 255, 0) 70%,
+ transparent 100%
+ );
+ background-position: -39.29% 0;
+ background-repeat: no-repeat;
+ background-size: 44% 100%;
+ filter: drop-shadow(0 0 2px rgba(22, 119, 255, 0.3));
+ pointer-events: none;
+ animation: fusionBorderSweepSync var(--fusion-sweep-duration) linear infinite;
+ animation-iteration-count: 1;
+ animation-fill-mode: both;
+}
+
+.fusion-label.sweep-reset::before,
+.fusion-label.sweep-reset .fusion-step.active::after {
+ animation: none !important;
+}
+
+.fusion-label.sweep-reset::before {
+ background-position: -39.29% 0;
+ opacity: 0.96;
+}
+
+.fusion-label.sweep-reset .fusion-step.active::after {
+ background-position: 95.45% 0, 0 0;
+}
+
+.fusion-label.sweep-wait::before {
+ animation: none !important;
+ opacity: 0;
+}
+
+.fusion-label.sweep-wait .fusion-step.active::after {
+ animation: none !important;
+ background: var(--blue);
+}
+
+.fusion-steps {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 4px;
+ min-width: 0;
+}
+
+.fusion-step {
+ position: relative;
+ min-width: 0;
+ padding-top: 1px;
+ color: #536175;
+ font-size: 9px;
+ font-weight: 650;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.fusion-step .label {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.fusion-step::after {
+ content: "";
+ display: block;
+ height: 5px;
+ margin-top: 4px;
+ border-radius: 999px;
+ background: #e8edf5;
+}
+
+.fusion-step.done {
+ color: #14704d;
+}
+
+.fusion-step.done::after {
+ background: #97d8ba;
+}
+
+.fusion-step.active {
+ color: #0b62cf;
+ font-weight: 760;
+}
+
+.fusion-step.active::after {
+ background:
+ linear-gradient(100deg, transparent 0 32%, rgba(255, 255, 255, 0.7) 44%, rgba(255, 255, 255, 0.9) 50%, rgba(255, 255, 255, 0.4) 57%, transparent 68%),
+ var(--blue);
+ background-size: 210% 100%, 100% 100%;
+ background-position: 95.45% 0, 0 0;
+ background-repeat: no-repeat, no-repeat;
+ box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
+ animation: fusionBarSweepSync var(--fusion-sweep-duration) linear infinite;
+ animation-iteration-count: 1;
+ animation-fill-mode: both;
+}
+
+@keyframes sweep {
+ from { transform: translateX(-100%); }
+ to { transform: translateX(100%); }
+}
+
+@keyframes typingCaret {
+ 0%,
+ 49% { opacity: 1; }
+ 50%,
+ 100% { opacity: 0; }
+}
+
+@keyframes fusionBarSweepSync {
+ from { background-position: 95.45% 0, 0 0; }
+ to { background-position: 4.55% 0, 0 0; }
+}
+
+@keyframes signalAbsorbGlow {
+ 0% { opacity: 0; transform: scale(0.72); }
+ 22% { opacity: 0.42; transform: scale(1); }
+ 100% { opacity: 0; transform: scale(1.9); }
+}
+
+@keyframes signalNodeInnerAbsorb {
+ 0% { opacity: 0; transform: scale(0.06); }
+ 22% { opacity: 0.98; transform: scale(1.02); }
+ 58% { opacity: 0.86; transform: scale(0.9); }
+ 100% { opacity: 0; transform: scale(0.28); }
+}
+
+@keyframes signalNodeChargeRing {
+ 0% { opacity: 0; transform: scale(0.72) rotate(-120deg); }
+ 18% { opacity: 0.96; transform: scale(1) rotate(-20deg); }
+ 58% { opacity: 0.82; transform: scale(1.02) rotate(140deg); }
+ 100% { opacity: 0; transform: scale(0.74) rotate(255deg); }
+}
+
+@keyframes signalAbsorbCore {
+ 0% { box-shadow: 0 0 0 3px #fff; }
+ 30% { box-shadow: 0 0 0 3px #fff, 0 0 0 5px rgba(22, 119, 255, 0.2), 0 0 14px rgba(22, 119, 255, 0.56); }
+ 100% { box-shadow: 0 0 0 3px #fff; }
+}
+
+@keyframes fusionBorderSweepSync {
+ 0% { background-position: -39.29% 0; opacity: 0.96; }
+ 100% { background-position: 139.29% 0; opacity: 0.96; }
+}
+
+#composer-input {
+ width: 100%;
+ min-height: 40px;
+ resize: none;
+ border: 0;
+ border-radius: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--ink);
+ padding: 0;
+ line-height: 1.45;
+}
+
+#composer-input:focus {
+ box-shadow: none;
+}
+
+.composer-box {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fbfdff;
+ padding: 10px 10px 9px;
+}
+
+.composer-box:focus-within {
+ border-color: var(--aliyun-blue);
+ box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.1);
+}
+
+.composer-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.composer-tools,
+.composer-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.primary-button,
+.secondary-button {
+ min-height: 36px;
+ border-radius: 8px;
+ padding: 0 16px;
+ font-weight: 700;
+}
+
+.primary-button {
+ border: 1px solid var(--aliyun-orange);
+ background: var(--aliyun-orange);
+ color: var(--white);
+}
+
+.secondary-button {
+ border: 1px solid var(--line);
+ background: var(--white);
+ color: var(--ink);
+}
+
+.secondary-button:hover {
+ border-color: #9ec9fb;
+ color: var(--aliyun-blue);
+}
+
+.composer .compact-button {
+ min-height: 32px;
+ padding: 0 12px;
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.icon-only-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.icon-only-button {
+ width: 32px;
+ height: 32px;
+ min-height: 32px;
+ border: 0;
+ background: transparent;
+ color: var(--ink);
+}
+
+.send-icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ min-height: 36px;
+ border-radius: 8px;
+ padding: 0;
+}
+
+.composer-divider {
+ width: 1px;
+ height: 24px;
+ background: var(--line);
+}
+
+.attachment-icon {
+ position: relative;
+ width: 15px;
+ height: 20px;
+ transform: rotate(42deg);
+}
+
+.attachment-icon::before,
+.attachment-icon::after {
+ content: "";
+ position: absolute;
+ border: 2px solid currentColor;
+ border-bottom: 0;
+ border-radius: 999px 999px 0 0;
+}
+
+.attachment-icon::before {
+ inset: 1px 2px 6px;
+}
+
+.attachment-icon::after {
+ inset: 5px 5px 8px;
+}
+
+.send-icon {
+ position: relative;
+ width: 15px;
+ height: 15px;
+}
+
+.send-icon::before {
+ content: "";
+ position: absolute;
+ inset: 2px 1px 1px 2px;
+ border-top: 3px solid var(--white);
+ border-right: 3px solid var(--white);
+ transform: rotate(45deg);
+}
+
+.send-icon::after {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 7px;
+ width: 12px;
+ height: 3px;
+ border-radius: 999px;
+ background: var(--white);
+ transform: rotate(-25deg);
+}
+
+.ai-disclaimer {
+ margin: 8px 0 0;
+ color: var(--subtle);
+ font-size: 12px;
+}
+
+.plan-area {
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100vh - 96px);
+}
+
+.connection-controls {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ align-items: start;
+ gap: 10px;
+ width: 100%;
+}
+
+.connection-controls label {
+ display: grid;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.connection-controls input {
+ width: 100%;
+ height: 36px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ outline: 0;
+ padding: 0 10px;
+ color: var(--ink);
+}
+
+.connection-controls input:focus-visible {
+ border-color: var(--aliyun-blue);
+ box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
+}
+
+.connection-actions {
+ display: flex;
+ flex-wrap: wrap;
+ grid-column: 1 / -1;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.danger-button {
+ color: #b42318;
+}
+
+.plans-grid {
+ display: grid;
+ align-items: start;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ min-width: 0;
+ padding: 16px;
+}
+
+.plan-card {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 248px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--white);
+ padding: 16px;
+}
+
+.plan-card.recommended {
+ border-color: #9ec9fb;
+}
+
+.plan-card.selected {
+ border-color: var(--aliyun-blue);
+ background: var(--surface-blue);
+ box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.14);
+}
+
+.plan-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.plan-card-header-meta {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ min-width: 0;
+}
+
+.tag {
+ border-radius: 14px;
+ background: #fff3eb;
+ color: var(--aliyun-orange);
+ padding: 4px 10px;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.tag.muted {
+ background: #eef2f6;
+ color: var(--muted);
+}
+
+.score {
+ color: var(--success);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.plan-status {
+ border-radius: 999px;
+ padding: 3px 8px;
+ font-size: 11px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.plan-status.working {
+ background: #eaf3ff;
+ color: var(--aliyun-blue);
+}
+
+.plan-status.completed {
+ background: #e9f8f1;
+ color: var(--success);
+}
+
+.plan-status.failed {
+ background: #fff1f0;
+ color: #b42318;
+}
+
+.plan-card h2 {
+ margin-bottom: 10px;
+ font-size: 18px;
+}
+
+.plan-card p {
+ margin-bottom: 18px;
+ color: var(--muted);
+ line-height: 1.6;
+}
+
+.price {
+ display: grid;
+ gap: 4px;
+ margin-top: auto;
+ color: var(--aliyun-orange);
+}
+
+.price-label {
+ color: var(--muted-light);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.price strong {
+ color: var(--aliyun-orange);
+ font-size: 24px;
+ font-weight: 800;
+}
+
+.plan-meta {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ margin: 16px 0 0;
+}
+
+.plan-meta div {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fbfdff;
+ padding: 10px;
+}
+
+.plan-meta dt {
+ color: var(--subtle);
+ font-size: 12px;
+}
+
+.plan-meta dd {
+ margin: 4px 0 0;
+ color: var(--ink);
+ font-weight: 700;
+}
+
+.debug-drawer {
+ margin: auto 16px 16px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fbfdff;
+}
+
+.debug-drawer summary {
+ padding: 12px 14px;
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.debug-panel {
+ display: grid;
+ gap: 14px;
+ border-top: 1px solid var(--line);
+ padding: 14px;
+}
+
+.debug-session-info {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ border: 1px solid #e0e7f2;
+ border-radius: 8px;
+ background: var(--white);
+ padding: 10px;
+}
+
+.debug-session-field {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+}
+
+.debug-session-field span {
+ color: var(--subtle);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.debug-session-field code {
+ overflow: hidden;
+ color: var(--ink);
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.progress-debug-panel {
+ display: grid;
+ gap: 12px;
+}
+
+.progress-debug-title {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.progress-debug-title strong {
+ font-size: 13px;
+}
+
+.progress-debug-title span {
+ color: var(--subtle);
+ font-size: 12px;
+}
+
+.progress-variant-switch {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.progress-variant-switch button {
+ min-width: 0;
+ min-height: 32px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--white);
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.progress-variant-switch button.selected {
+ border-color: #8fc2ff;
+ background: var(--surface-blue);
+ color: var(--aliyun-blue);
+}
+
+.progress-param-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px 12px;
+}
+
+.progress-param-grid[hidden] {
+ display: none;
+}
+
+.progress-param {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.progress-param-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: #46566b;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.progress-param output {
+ color: var(--aliyun-blue);
+ font-variant-numeric: tabular-nums;
+}
+
+.progress-param input[type="range"] {
+ width: 100%;
+ accent-color: var(--aliyun-blue);
+}
+
+.step-switch {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 5px;
+ margin-top: 6px;
+}
+
+.step-switch button {
+ height: 22px;
+ border: 1px solid #cbd8e8;
+ border-radius: 6px;
+ background: #fff;
+ color: #475569;
+ font-size: 9px;
+ font-weight: 720;
+ cursor: pointer;
+}
+
+.step-switch button.active {
+ border-color: rgba(22, 119, 255, 0.72);
+ background: rgba(22, 119, 255, 0.1);
+ color: var(--blue);
+}
+
+.progress-demo-step-control {
+ margin-top: 0;
+ padding: 7px 8px;
+ padding-top: 10px;
+ border-top: 2px solid rgba(22, 119, 255, 0.78);
+ border-right: 1px solid #e0e7f2;
+ border-bottom: 1px solid #e0e7f2;
+ border-left: 1px solid #e0e7f2;
+ border-radius: 8px;
+ background: #fbfdff;
+}
+
+.progress-demo-step-control label {
+ display: flex;
+ justify-content: space-between;
+ gap: 6px;
+ color: #475569;
+ font-size: 12px;
+ font-weight: 650;
+ line-height: 1.2;
+}
+
+.progress-demo-step-control output {
+ color: var(--blue);
+ font-weight: 760;
+}
+
+.debug-output-block {
+ display: grid;
+ gap: 8px;
+}
+
+.debug-output-block summary {
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.35;
+}
+
+.debug-drawer pre {
+ margin: 0;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--white);
+ color: var(--muted);
+ overflow: auto;
+ padding: 14px;
+}
+
+.utility-rail {
+ padding-top: 8px;
+}
+
+.utility-rail button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ background: var(--white);
+ color: var(--muted);
+ box-shadow: 0 4px 12px rgba(31, 41, 55, 0.06);
+}
+
+.utility-rail button:hover {
+ border-color: #9ec9fb;
+ color: var(--aliyun-blue);
+}
+
+@media (max-width: 1180px) {
+ .topbar-links {
+ display: none;
+ }
+
+ .console-shell {
+ grid-template-columns: minmax(240px, 347px) minmax(0, 1fr);
+ }
+
+ .utility-rail {
+ display: none;
+ }
+}
+
+@media (max-width: 980px) {
+ .topbar {
+ flex-wrap: wrap;
+ height: auto;
+ min-height: 64px;
+ padding: 12px 16px;
+ }
+
+ .brand {
+ min-width: 170px;
+ }
+
+ .topbar-search {
+ order: 10;
+ width: 100%;
+ max-width: none;
+ }
+
+ .topbar-nav {
+ display: none;
+ }
+
+ .console-shell {
+ grid-template-columns: 1fr;
+ height: auto;
+ min-height: calc(100vh - 64px);
+ padding: 12px;
+ }
+
+ .workflow-panel,
+ .plan-area {
+ height: auto;
+ min-height: auto;
+ }
+
+ .panel-heading,
+ .plan-header {
+ flex-direction: column;
+ }
+
+ .connection-controls,
+ .plans-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .composer-progress {
+ grid-template-columns: repeat(5, minmax(48px, 1fr));
+ overflow-x: auto;
+ }
+
+ .connection-actions {
+ justify-content: stretch;
+ }
+
+ .connection-actions .secondary-button {
+ flex: 1 1 140px;
+ }
+}
+
+@media (max-width: 560px) {
+ .topbar {
+ gap: 10px;
+ }
+
+ .user-summary {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .composer-toolbar,
+ .composer-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .primary-button,
+ .secondary-button {
+ width: 100%;
+ }
+
+ .composer-toolbar,
+ .composer-actions {
+ align-items: center;
+ flex-direction: row;
+ }
+
+ .composer .secondary-button {
+ width: auto;
+ }
+
+ .composer .send-icon-button {
+ width: 36px;
+ }
+
+ .composer .icon-only-button {
+ width: 32px;
+ }
+
+ .plan-meta {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/scripts/repl/e2e/README.md b/scripts/repl/e2e/README.md
new file mode 100644
index 00000000..439e5481
--- /dev/null
+++ b/scripts/repl/e2e/README.md
@@ -0,0 +1,19 @@
+# REPL Pipeline E2E Runner
+
+This directory contains real terminal end-to-end helpers for pipeline behavior. The runner drives the REPL through a real PTY and is POSIX-only because it uses `pexpect`.
+
+Run from the repository root:
+
+```bash
+uv run python scripts/repl/e2e/run_pipeline_scenarios.py --help
+```
+
+By default, run artifacts are written under the system temporary directory, in:
+
+```text
+iac-code-repl-e2e-runs/
/--/
+```
+
+Use `--run-dir` to choose a fixed collection directory for local debugging or CI smoke artifacts.
+
+The runner is for manual or smoke validation. It uses the developer's configured provider and may call real Alibaba Cloud tools when `--allow-real-cloud` is enabled. It must not require real LLMs or real cloud credentials in automated unit tests; pytest coverage for this directory is limited to pure helpers and argument behavior.
diff --git a/scripts/repl/e2e/README.zh-CN.md b/scripts/repl/e2e/README.zh-CN.md
new file mode 100644
index 00000000..16378b75
--- /dev/null
+++ b/scripts/repl/e2e/README.zh-CN.md
@@ -0,0 +1,150 @@
+# REPL Pipeline E2E
+
+本目录包含通过真实交互式终端回归 pipeline 功能的脚本。它和
+`scripts/a2a/e2e/run_recovery_scenarios.py` 目标相同,都是回归 pipeline;区别是这里走真实
+REPL / PTY 入口,而 A2A runner 走 JSON-RPC / SSE 入口。
+
+## 重要说明
+
+- 默认使用当前用户真实 `~/.iac-code` 配置。
+- 会调用真实 LLM provider。
+- 带 `--allow-real-cloud` 的 pipeline 场景可能调用真实阿里云工具和凭证。
+- 不属于普通 `make test`,也不会在 pytest 中执行真实场景。
+- 该 runner 通过 `pexpect` 使用真实 PTY,仅支持 POSIX 环境;Windows 会提前报错,不作为本脚本支持目标。
+
+## 快速开始
+
+```bash
+PATH="$HOME/.local/bin:$PATH" \
+uv run python scripts/repl/e2e/run_pipeline_scenarios.py \
+ --allow-real-cloud \
+ --scenario scenario1
+```
+
+指定 provider/model 但不写入 `settings.yml`:
+
+```bash
+PATH="$HOME/.local/bin:$PATH" \
+uv run python scripts/repl/e2e/run_pipeline_scenarios.py \
+ --allow-real-cloud \
+ --provider dashscope \
+ --model qwen3.6-plus \
+ --scenario scenario1
+```
+
+## 场景
+
+| 场景 | 覆盖 |
+| --- | --- |
+| `scenario1` | 通过 REPL 完成 VSwitch pipeline、候选方案选择、handoff normal chat |
+| `ask-waiting` | 通过 REPL 回复澄清问题后继续 pipeline,并完成 VSwitch 创建 |
+| `ask-waiting-resume` | ask user question 等待时杀进程,重启后重放问题并继续 |
+| `image-initial` | 首轮用户输入通过 bracketed paste 粘贴静态 `initial.png` 图片,随后选择候选并完成 VSwitch 创建 |
+| `image-ask-waiting-resume` | ask user question 等待时杀进程,`--continue` 恢复后通过静态图片回答澄清问题并继续 |
+| `image-selection-waiting-resume` | 首轮图片启动 pipeline,candidate selection 等待时杀进程,重启后恢复选择 UI 并继续 |
+| `image-normal-handoff` | pipeline handoff 到 normal chat 后,通过静态图片追问“你刚才创建了什么” |
+| `image-interrupt` | evaluate candidates 阶段发送 Esc 后,通过静态图片输入回退到安全组的 interrupt 指令 |
+| `selection-waiting-resume` | candidate selection 等待时杀进程,重启后恢复选择 UI 并继续 |
+| `selection-invalid-then-valid` | candidate selection 中先发送无效选择,再发送有效选择并完成 |
+| `evaluate-resume` | evaluate candidates 阶段杀进程,重启后重放中断点,发送 `continue` 后继续到选择并完成 |
+| `rollback-step2` | architecture planning 中发送 Esc 和回退指令,验证 streaming interrupt 路径 |
+| `rollback-step3` | pipeline 中发送 Esc 和回退指令,验证 REPL hard interrupt 路径 |
+| `rollback-step4-selection` | candidate selection 中发送 Esc 和回退指令,验证 selection tabs interrupt 路径 |
+| `rollback-step5-cleanup` | deploying 创建真实 ROS stack 后回退,验证旧 stack 被 cleanup 删除、新 stack 保留 |
+| `rollback-step5-cleanup-recovery` | cleanup 删除中杀进程,`--continue` 恢复后验证 cleanup 重新触发并完成 |
+
+## 验收标准
+
+脚本的通过条件不是“进程退出 0”或“某个 regex 被等到”本身,而是 `summary.json` 里的所有
+`checks` 都为 `true`。其中 `acceptance:` 前缀的检查项来自 PTY transcript,是回归验收标准:
+
+- 通用:必须捕获到 PTY transcript,且 transcript 中不能出现 traceback、pexpect EOF/TIMEOUT、权限拒绝等终端错误。
+- `scenario1`:必须展示 candidate selection,完成 pipeline,并在 PTY transcript 中出现 VSwitch 证据(例如 `VSwitchId`、`vsw-...` 或交换机 ID);进入 normal chat 后,`你刚才创建了什么` 的回答必须提到 VSwitch/交换机,不能只验证“有输出”。
+- `ask-waiting`:必须展示真实 `Ask user question`,回答澄清问题后如果进入 candidate selection,必须继续选择候选并完成 pipeline;最终 PTY transcript 必须出现 VSwitch 证据。
+- `ask-waiting-resume`:必须在 `--continue` 后重放 `Ask user question`;回答后如果进入 candidate selection,必须继续选择候选并完成 pipeline;最终 PTY transcript 必须出现 VSwitch 证据。
+- `image-initial`:必须记录 `initial` 静态图片 fixture 的 paste 事件;图片输入必须启动 pipeline、展示 candidate selection、完成 pipeline,并出现 VSwitch 证据。
+- `image-ask-waiting-resume`:必须在 `--continue` 后重放 `Ask user question`;恢复后的回答必须通过 `ask-first-answer` 静态图片 fixture 输入;如果模型继续追问,runner 会再用 `ask-second-answer` 静态图片回答;最终必须继续到 candidate selection 或 pipeline completed,并出现 VSwitch 证据。
+- `image-selection-waiting-resume`:必须记录 `initial` 静态图片 fixture 的 paste 事件;candidate selection 必须在 `--continue` 后重放;随后通过真实候选 UI 数字键选择并完成 pipeline,最终出现 VSwitch 证据。
+- `image-normal-handoff`:必须完成 pipeline handoff normal chat;normal follow-up 必须通过 `normal-followup` 静态图片 fixture 输入,且回答必须提到 VSwitch/交换机。
+- `image-interrupt`:必须先到达 `Evaluate candidates (3/5)`;发送 Esc 进入 interrupt 输入后,必须通过 `rollback-interrupt` 静态图片 fixture 输入;图片 interrupt 之后必须看到新的 pipeline 进展,回退后的输出必须指向安全组目标且不能指向 VSwitch。
+- `selection-waiting-resume`:必须在 `--continue` 后重放 candidate selection,最终完成 pipeline,并出现 VSwitch 证据。
+- `selection-invalid-then-valid`:必须记录无效选择输入,然后记录有效选择输入,最终完成 pipeline,并出现 VSwitch 证据。
+- `evaluate-resume`:必须先到达 `Evaluate candidates (3/5)`,使用 `--continue` 恢复并重放该步骤;恢复后的普通 REPL prompt ready 后发送 `continue`,随后必须继续到 candidate selection 或 pipeline completed;最终 PTY transcript 必须出现 VSwitch 证据。
+- `rollback-step2`:必须先到达 `Architecture planning (2/5)`;发送回退指令之后必须看到新的 pipeline 进展;回退后的输出必须指向安全组目标,且不能把用户输入 echo 里的“安全组”当作通过证据。
+- `rollback-step3`:必须先到达 `Evaluate candidates (3/5)`;step3 的 parallel tabs 中断输入不要求 transcript 出现普通 `✎` prompt;发送回退指令之后必须看到新的 pipeline 进展(例如新的 `Intent parsing (1/5)`),不能用用户输入 echo 里的“回退”当作通过证据;回退后的输出必须指向安全组目标,且不能指向 VSwitch。
+- `rollback-step4-selection`:必须先到达 `Confirm and select (4/5)`;selection tabs 中断输入同样不要求 transcript 出现普通 `✎` prompt;发送回退指令之后必须看到新的 pipeline 进展;回退后的输出必须指向安全组目标,且不能指向 VSwitch。
+- `rollback-step5-cleanup`:必须到达 deploying 并观察到第一次 CreateStack;回退后 `cleanup.yaml` 必须把第一次 stack 记录为 cleanup target;第二次部署必须创建不同 stack;normal chat 前置 cleanup 必须完成;ROS GetStack 必须确认第一次 stack 已删除,第二次 stack 仍保留。
+- `rollback-step5-cleanup-recovery`:在 `rollback-step5-cleanup` 的基础上,cleanup 开始后必须杀掉 REPL 子进程,随后用 `--continue` 恢复;恢复后必须重新触发 cleanup 并完成;ROS GetStack 同样必须确认旧 stack 删除、新 stack 保留。
+
+会创建资源的非 cleanup 场景还必须在当前 REPL session 的 `pipeline/cleanup.yaml` 中观察到 ROS
+`CreateStack` 资源,且 StackName 必须是 runner 为当前场景注入的 `iac-e2e-*` test-owned 名称;
+teardown 前会通过 ROS GetStack 确认这些 stack 仍存在。场景结束后,runner 会自动删除这些
+observed stack;删除前会再次校验云端 StackName 必须等于 ledger 记录的 test-owned StackName,
+避免误删非本轮测试资源。
+
+`rollback-step3` 和 `rollback-step4-selection` 会在发送 Esc 后等待第二个 raw-input ready 控制序列,再输入回退指令;这对应 tabs UI 切入中断文本输入行之后的真实 PTY 状态。
+
+`rollback-step5-cleanup*` 的通过条件不只看 PTY 文本,还会读取同一个 REPL session 下的
+`pipeline/cleanup.yaml`,并在最后写出 `acceptance-after-cleanup.ros-stack-states.json` 作为真实 ROS
+状态快照。这样可以避免“终端看起来清理了,但 ledger 或云端状态不对”的假阳性。
+
+pipeline completed 的匹配必须是终态证据,例如 `Pipeline completed`、`CREATE_COMPLETE`、`部署成功` 或
+`Stack ID`;候选方案里的 `Completed` 或“参数选择完成”不能作为通过证据。
+
+## 图片场景输入方式
+
+REPL image 场景复用 `scripts/a2a/e2e/fixtures/text-images/` 下的静态 PNG fixture,避免每次运行时重新
+生成图片。runner 不依赖系统剪贴板,而是通过 PTY 发送 bracketed paste 序列:
+
+```text
+ESC [ 200 ~ ESC [ 201 ~
+```
+
+REPL 会把这个路径交给普通模式相同的 bracketed-paste 处理逻辑,解析图片文件、持久化到 image cache,
+并在 prompt 中插入 `[Image #N]`。因此这些场景验证的是真实 REPL 图片入口,而不是测试脚本直接构造
+`ImageBlock`。
+
+## 产物
+
+默认写入系统临时目录下的 `iac-code-repl-e2e-runs//--/`:
+
+- `summary.json`:场景结果、检查点、耗时、失败原因。
+- `events.jsonl`:spawn/send/expect/terminate 等黑盒终端事件。
+- `child.env.json`:子进程环境摘要,敏感值会被脱敏。
+- `transcript.raw.log`:脱敏后的原始终端 transcript。
+- `transcript.normalized.log`:去 ANSI/control 字符后的 transcript,便于 diff 和排查。
+- `acceptance-after-cleanup.ros-stack-states.json`:cleanup 场景的 ROS GetStack 快照,敏感值会被脱敏。
+
+使用固定目录便于 CI 或本地脚本收集:
+
+```bash
+PATH="$HOME/.local/bin:$PATH" \
+uv run python scripts/repl/e2e/run_pipeline_scenarios.py \
+ --allow-real-cloud \
+ --scenario selection-waiting-resume \
+ --run-dir "$(python - <<'PY'
+import tempfile
+from pathlib import Path
+print(Path(tempfile.gettempdir()) / 'iac-code-repl-e2e-selection')
+PY
+)"
+```
+
+## 常用参数
+
+- `--scenario` 可重复传入;默认只跑 `scenario1`。
+- `--cwd` 指定 REPL 子进程工作目录;默认使用 run dir 下的 `workspace/`。
+- `--timeout` 控制普通终端等待。
+- `--stream-timeout` 控制 LLM/pipeline 长等待。
+- `--selection-prompt` 指定候选方案选择输入;默认发送 `1` 选择第一个候选;传空字符串时直接回车确认。
+- `--evaluate-resume-continue-prompt` 指定 `evaluate-resume` 在 `--continue` 重放后用于继续 running sidecar 的输入;默认 `continue`。
+- `--cleanup-continue-prompt` 指定 `rollback-step5-cleanup-recovery` 在 `--continue` 恢复后用于继续 cleanup 的输入;默认只允许删除待清理列表中的 stack,避免误删其他资源。
+- `--permission-prompt-response` 指定工具权限确认菜单的输入;默认 `pageup-enter`(发送 PageUp+Enter,选择第一项 `Yes, allow once`)。
+- `--skip-final-teardown` 调试时跳过测试创建 stack 的最终删除;日常回归不要开启。
+- `--leave-running` 调试时保留子进程,不自动 terminate。
+
+## 与 pytest 的关系
+
+`tests/repl_e2e/test_run_pipeline_scenarios.py` 只覆盖脚本的纯 helper、参数校验、脱敏、dispatch
+流程,不会启动真实 REPL,也不会调用真实 LLM 或云账号。真实回归必须显式运行本目录脚本,并带上
+`--allow-real-cloud`。
diff --git a/scripts/repl/e2e/run_pipeline_scenarios.py b/scripts/repl/e2e/run_pipeline_scenarios.py
new file mode 100644
index 00000000..b1a59b98
--- /dev/null
+++ b/scripts/repl/e2e/run_pipeline_scenarios.py
@@ -0,0 +1,2672 @@
+#!/usr/bin/env python3
+"""Run real interactive REPL pipeline E2E scenarios.
+
+This runner intentionally drives the public terminal interface through a PTY.
+It uses the user's real configuration by default and must not be imported by
+ordinary package code.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import ipaddress
+import json
+import os
+import re
+import shlex
+import signal
+import tempfile
+import time
+import uuid
+from collections.abc import Callable, Iterable
+from dataclasses import asdict, dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+try:
+ import pexpect
+except ImportError: # pragma: no cover - exercised manually when dependency missing
+ pexpect = None # type: ignore[assignment]
+
+try:
+ import yaml
+except ImportError: # pragma: no cover - PyYAML is part of the project runtime
+ yaml = None # type: ignore[assignment]
+
+
+RUN_LOG_ROOT_NAME = "iac-code-repl-e2e-runs"
+PTY_SEND_CHUNK_SIZE = 512
+PTY_SEND_CHUNK_DELAY_SECONDS = 0.01
+TEXT_IMAGE_FIXTURE_ROOT = Path(__file__).resolve().parents[2] / "a2a" / "e2e" / "fixtures" / "text-images"
+TEXT_IMAGE_FIXTURE_FILENAMES = {
+ "initial": "initial.png",
+ "selection": "selection.png",
+ "normal-followup": "normal-followup.png",
+ "ask-first-answer": "ask-first-answer.png",
+ "ask-second-answer": "ask-second-answer.png",
+ "rollback-interrupt": "rollback-interrupt.png",
+}
+DEFAULT_INITIAL_PROMPT = "选择一个已有vpc,创建一个vswitch"
+DEFAULT_SELECTION_PROMPT = "1"
+DEFAULT_ASK_PROMPT = "我有个产品要上线"
+DEFAULT_ASK_ANSWER = "我要创建云网络资源;本次只选择已有 VPC 创建一个 VSwitch,不部署 ECS、EIP、SLB 或 Nginx。"
+DEFAULT_NORMAL_FOLLOWUP_PROMPT = "你刚才创建了什么"
+DEFAULT_ROLLBACK_PROMPT = "回退到 intent_parsing,选择一个已有vpc,创建一个安全组"
+DEFAULT_INVALID_SELECTION_PROMPT = "9"
+DEFAULT_EVALUATE_RESUME_CONTINUE_PROMPT = "continue"
+DEFAULT_CLEANUP_CONTINUE_PROMPT = (
+ "只执行上面的回滚清理:仅删除待清理列表中的 stack id,完成后停止,不要删除或检查其他 stack。"
+)
+DEFAULT_PERMISSION_PROMPT_RESPONSE = "pageup-enter"
+
+PIPELINE_STARTED_PATTERNS = (r"Pipeline", r"pipeline", r"intent_parsing", r"意图")
+CANDIDATE_SELECTION_PATTERNS = (
+ r"(?i)Confirm and select\s*\(\d+/\d+\)",
+ r"(?i)confirm[ _-]+and[ _-]+select\s*\(\d+/\d+\)",
+ r"确认并选择\s*\(\d+/\d+\)",
+ r"候选选择\s*\(\d+/\d+\)",
+)
+CANDIDATE_EVALUATION_PATTERNS = (r"(?i)Evaluate candidates\s*\(\d+/\d+\)", r"evaluate_candidates")
+ARCHITECTURE_PLANNING_PATTERNS = (r"(?i)Architecture planning\s*\(\d+/\d+\)", r"architecture_planning")
+ASK_PATTERNS = (r"Ask user question", r"请.*输入", r"请.*补充", r"请描述", r"需要.*信息", r"澄清", r"问题")
+PIPELINE_COMPLETED_PATTERNS = (
+ r"(?i)Pipeline completed",
+ r"CREATE_COMPLETE",
+ r"部署成功",
+ r"Stack ID",
+ r"(?i)handoff",
+ r"交接",
+)
+PIPELINE_FULLY_COMPLETED_PATTERNS = (r"(?i)Pipeline completed\.\s+Normal chat is now active\.",)
+POST_ROLLBACK_PROGRESS_PATTERNS = (
+ r"●\s*Intent parsing\s*\(1/5\)",
+ r"●\s*Architecture planning\s*\(2/5\)",
+ r"Step Intent parsing completed",
+)
+DEPLOYING_STEP_PATTERNS = (r"●\s*Deploying\s*\(5/5\)", r"CreateStack", r"开始部署")
+CREATE_STACK_STARTED_PATTERNS = (r"ROS Stack\(CreateStack", r"CreateStack")
+FIRST_STACK_CREATED_PATTERNS = (r"CREATE_COMPLETE", r"Stack ID", r"StackId", r"stack_id")
+CLEANUP_STARTED_PATTERNS = (
+ r"检测到\s*\d+\s*个回滚残留资源",
+ r"开始清理流程",
+ r"回滚清理\s*\[删除中\]",
+ r"DeleteStack",
+)
+CLEANUP_RESUME_SUMMARY_PATTERNS = (r"回滚清理恢复", r"回滚清理")
+CLEANUP_COMPLETED_PATTERNS = (r"DELETE_COMPLETE", r"回滚清理\s*\[完成\]", r"清理.*完成")
+CLEANUP_DEPLOYMENT_FAILURE_PATTERNS = (
+ r"\bCREATE_FAILED\b",
+ r"RouteConflict",
+ r"StackExists",
+ r"InvalidCidrBlock",
+)
+ROS_STACK_DELETED_STATUSES = {"DELETE_COMPLETE"}
+REPL_PROMPT_PATTERNS = (r"❯",)
+REPL_INPUT_READY_PATTERNS = (r"\x1b\[>4;2m",)
+INTERRUPT_INPUT_PATTERNS = (r"✎", r"interrupt", r"输入", r"Judging")
+CANDIDATE_SELECTION_READY_PATTERNS = (
+ r"Press number keys to select a candidate",
+ r"Enter to confirm",
+ r"按数字键.*候选",
+)
+PERMISSION_PROMPT_PATTERNS = (
+ r"Yes, allow once",
+ r"允许一次",
+)
+TERMINAL_ERROR_PATTERNS = (
+ r"Traceback \(most recent call last\)",
+ r"pexpect\.(?:TIMEOUT|EOF)",
+ r"rejected_in_prompt",
+ r"Permission.*reject",
+ r"权限.*拒绝",
+)
+VSWITCH_EVIDENCE_PATTERNS = (
+ r"ALIYUN::ECS::VSwitch",
+ r"VSwitchId",
+ r"vsw-[A-Za-z0-9]+",
+ r"交换机\s*ID",
+)
+VSWITCH_MENTION_PATTERNS = (
+ r"(?i)VSwitch",
+ r"交换机",
+ r"vsw-[A-Za-z0-9]+",
+)
+SECURITY_GROUP_EVIDENCE_PATTERNS = (
+ r"ALIYUN::ECS::SecurityGroup",
+ r"SecurityGroupId",
+ r"sg-[A-Za-z0-9]+",
+ r"安全组\s*ID",
+)
+SECURITY_GROUP_MENTION_PATTERNS = (
+ r"(?i)SecurityGroup",
+ r"安全组",
+ r"sg-[A-Za-z0-9]+",
+)
+POSITIVE_VSWITCH_TARGET_PATTERNS = (
+ r"ALIYUN::ECS::VSwitch",
+ r"VSwitchId",
+ r"vsw-[A-Za-z0-9]+",
+ r"(?:创建|新建|目标资源|资源类型|部署).*?(?:VSwitch|交换机)",
+)
+NEGATED_VSWITCH_TARGET_LINE_PATTERNS = (
+ r"(?i)(?:不|不要|禁止|避免|无需|不再|不能|不得|forbid|forbidden).*?(?:VSwitch|交换机)",
+ r"(?i)(?:VSwitch|交换机).*?(?:forbid|forbidden|不创建|禁止|不要|无需)",
+ r"(?i)(?:no|without).*?(?:VSwitch|switch)",
+ r"(?i)(?:VSwitch|switch).*?(?:no|without)",
+ r"(?i)(?:从|由|将需求从|把需求从).*?(?:创建|新建).*?(?:VSwitch|交换机).*?(?:改为|变更为|切换为|转为).*?(?:SecurityGroup|安全组)",
+ r"(?i)from.*?(?:create|creating).*?(?:VSwitch|switch).*?to.*?(?:SecurityGroup|security group)",
+ r'(?i)"product"\s*:\s*"VSwitch".*?"action"\s*:\s*"forbid"',
+)
+NEGATED_VSWITCH_TARGET_SPAN_PATTERNS = (
+ r"(?is)(?:从|由|将需求从|把需求从).{0,80}(?:创建|新建).{0,80}(?:VSwitch|交换机).{0,160}(?:改为|变更为|切换为|转为).{0,80}(?:SecurityGroup|安全组)",
+ r"(?is)from.{0,80}(?:create|creating).{0,80}(?:VSwitch|switch).{0,160}to.{0,80}(?:SecurityGroup|security group)",
+)
+ARCHITECTURE_PLANNING_HEADING_PATTERNS = (r"●\s*Architecture planning\s*\(2/5\)",)
+EVALUATE_CANDIDATES_HEADING_PATTERNS = (r"●\s*Evaluate candidates\s*\(3/5\)",)
+ASK_USER_QUESTION_HEADING_PATTERNS = (r"●\s*Ask user question",)
+
+STACK_CREATING_SCENARIOS = frozenset(
+ {
+ "scenario1",
+ "ask-waiting",
+ "ask-waiting-resume",
+ "image-initial",
+ "image-ask-waiting-resume",
+ "image-selection-waiting-resume",
+ "image-normal-handoff",
+ "selection-waiting-resume",
+ "selection-invalid-then-valid",
+ "evaluate-resume",
+ }
+)
+
+
+@dataclass
+class ScenarioRunResult:
+ scenario: str
+ run_dir: str
+ passed: bool
+ checks: dict[str, bool]
+ elapsed_seconds: float
+ abort_reason: str = ""
+ notes: list[str] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class CleanupNetworkTarget:
+ vpc_id: str
+ vpc_cidr: str
+ zone_id: str
+ vswitch_cidr: str
+ rollback_vswitch_cidr: str
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run interactive REPL pipeline E2E scenarios.")
+ parser.add_argument(
+ "--scenario",
+ action="append",
+ choices=sorted(_SCENARIOS),
+ help="Scenario to run. Can be repeated. Defaults to scenario1.",
+ )
+ parser.add_argument("--allow-real-cloud", action="store_true")
+ parser.add_argument("--cwd", default="", help="Child process cwd. Defaults to /workspace.")
+ parser.add_argument("--run-root", default=str(Path(tempfile.gettempdir()) / RUN_LOG_ROOT_NAME))
+ parser.add_argument("--run-dir", default="", help="Explicit run dir. Only valid with one scenario.")
+ parser.add_argument("--python", default="uv run python")
+ parser.add_argument("--provider", default="")
+ parser.add_argument("--model", default="")
+ parser.add_argument("--api-base", default="")
+ parser.add_argument("--timeout", type=float, default=45.0)
+ parser.add_argument("--stream-timeout", type=float, default=1800.0)
+ parser.add_argument("--terminal-width", type=int, default=140)
+ parser.add_argument("--terminal-height", type=int, default=40)
+ parser.add_argument("--candidate-selection-ready-timeout", type=float, default=30.0)
+ parser.add_argument("--leave-running", action="store_true")
+ parser.add_argument(
+ "--skip-final-teardown",
+ action="store_true",
+ help="Do not delete test-owned ROS stacks after scenario acceptance checks.",
+ )
+ parser.add_argument("--final-teardown-timeout", type=float, default=900.0)
+ parser.add_argument("--cleanup-vpc-id", default="", help="Existing VPC ID to use for cleanup E2E scenarios.")
+ parser.add_argument("--cleanup-vpc-cidr", default="", help="CIDR of --cleanup-vpc-id, used only in prompts.")
+ parser.add_argument("--cleanup-zone-id", default="", help="Zone ID to use for cleanup E2E scenarios.")
+ parser.add_argument(
+ "--cleanup-vswitch-cidr",
+ default="",
+ help="Free VSwitch CIDR to use for the first stack in cleanup E2E scenarios.",
+ )
+ parser.add_argument(
+ "--cleanup-rollback-vswitch-cidr",
+ default="",
+ help="Different free VSwitch CIDR to use for the post-rollback stack in cleanup E2E scenarios.",
+ )
+ parser.add_argument("--initial-prompt", default=DEFAULT_INITIAL_PROMPT)
+ parser.add_argument("--selection-prompt", default=DEFAULT_SELECTION_PROMPT)
+ parser.add_argument(
+ "--permission-prompt-response",
+ default=DEFAULT_PERMISSION_PROMPT_RESPONSE,
+ help="Permission prompt response: pageup-enter, up-enter, enter, or literal text.",
+ )
+ parser.add_argument("--ask-prompt", default=DEFAULT_ASK_PROMPT)
+ parser.add_argument("--ask-answer", default=DEFAULT_ASK_ANSWER)
+ parser.add_argument("--normal-followup-prompt", default=DEFAULT_NORMAL_FOLLOWUP_PROMPT)
+ parser.add_argument("--rollback-prompt", default=DEFAULT_ROLLBACK_PROMPT)
+ parser.add_argument("--invalid-selection-prompt", default=DEFAULT_INVALID_SELECTION_PROMPT)
+ parser.add_argument("--evaluate-resume-continue-prompt", default=DEFAULT_EVALUATE_RESUME_CONTINUE_PROMPT)
+ parser.add_argument("--cleanup-continue-prompt", default=DEFAULT_CLEANUP_CONTINUE_PROMPT)
+ return parser.parse_args(argv)
+
+
+def _selected_scenarios(args: argparse.Namespace) -> list[str]:
+ return args.scenario or ["scenario1"]
+
+
+def _validate_scenario_execution(args: argparse.Namespace, scenario: str) -> None:
+ if scenario in _REAL_CLOUD_SCENARIOS and not args.allow_real_cloud:
+ raise SystemExit("refusing to run real REPL pipeline scenario without --allow-real-cloud: " + scenario)
+
+
+def _split_python_command(value: str) -> list[str]:
+ parts = shlex.split(value, posix=(os.name != "nt"))
+ if not parts:
+ raise ValueError("--python must not be empty")
+ return parts
+
+
+def _build_child_env(args: argparse.Namespace) -> dict[str, str]:
+ env = os.environ.copy()
+ env["PYTHONUTF8"] = "1"
+ env["IAC_CODE_MODE"] = "pipeline"
+ if args.provider:
+ env["IAC_CODE_PROVIDER"] = args.provider
+ if args.model:
+ env["IAC_CODE_MODEL"] = args.model
+ if args.api_base:
+ env["IAC_CODE_BASE_URL"] = args.api_base
+ return env
+
+
+def _redact_sensitive_text(text: str, env: dict[str, str] | None) -> str:
+ redacted = text
+ for name, value in (env or {}).items():
+ if not value or len(value) < 6:
+ continue
+ upper = name.upper()
+ if any(marker in upper for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL")):
+ redacted = redacted.replace(value, "")
+ redacted = re.sub(r"(?i)(api[_ -]?key\s*[:=]\s*)[^\s,'\"}]+", r"\1", redacted)
+ redacted = re.sub(r"(?i)(authorization\s*[:=]\s*)[^\s,'\"}]+", r"\1", redacted)
+ redacted = re.sub(r"(?", redacted)
+ return redacted
+
+
+_ANSI_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
+
+
+def _normalize_transcript(text: str) -> str:
+ text = _ANSI_PATTERN.sub("", text)
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
+ text = text.replace("\b", "")
+ return "\n".join(line.rstrip() for line in text.splitlines())
+
+
+def _compact_text(text: str, *, max_chars: int = 800) -> str:
+ compact = " ".join(text.split())
+ if len(compact) <= max_chars:
+ return compact
+ return compact[: max_chars - 3] + "..."
+
+
+def _permission_prompt_response_sequence(value: str) -> str:
+ if value == "pageup-enter":
+ return "\x1b[5~\r"
+ if value == "up-enter":
+ return "\x1b[A\r"
+ if value == "enter":
+ return "\r"
+ return f"{value}\r" if value else "\r"
+
+
+def _sendline_to_child(child: Any, text: str, *, capture: Callable[[str], None] | None = None) -> None:
+ if len(text) <= PTY_SEND_CHUNK_SIZE:
+ child.sendline(text)
+ return
+ for offset in range(0, len(text), PTY_SEND_CHUNK_SIZE):
+ child.send(text[offset : offset + PTY_SEND_CHUNK_SIZE])
+ _drain_child_output(child, capture=capture)
+ time.sleep(PTY_SEND_CHUNK_DELAY_SECONDS)
+ _drain_child_output(child, capture=capture)
+ child.sendline("")
+
+
+def _drain_child_output(child: Any, *, capture: Callable[[str], None] | None = None) -> None:
+ reader = getattr(child, "read_nonblocking", None)
+ if not callable(reader):
+ return
+ while True:
+ try:
+ text = reader(size=4096, timeout=0)
+ except Exception as exc:
+ if pexpect is not None and isinstance(exc, pexpect.TIMEOUT):
+ return
+ return
+ if not text:
+ return
+ if capture is not None:
+ capture(str(text))
+
+
+def _new_run_dir(root: Path) -> Path:
+ run_name = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
+ return root / run_name
+
+
+def _write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8")
+
+
+def _append_jsonl(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(value, ensure_ascii=False, default=str) + "\n")
+
+
+def _redacted_env_summary(env: dict[str, str]) -> dict[str, str]:
+ keys = ["HOME", "IAC_CODE_CONFIG_DIR", "IAC_CODE_MODE", "IAC_CODE_PROVIDER", "IAC_CODE_MODEL", "IAC_CODE_BASE_URL"]
+ return {key: _redact_sensitive_text(env[key], env) for key in keys if key in env}
+
+
+def _scenario_run_dir(args: argparse.Namespace, scenario: str) -> Path:
+ if args.run_dir:
+ return Path(args.run_dir).expanduser().resolve()
+ return _new_run_dir(Path(args.run_root).expanduser().resolve() / scenario)
+
+
+class ReplPty:
+ def __init__(self, *, args: argparse.Namespace, run_dir: Path, cwd: Path, env: dict[str, str]) -> None:
+ if os.name == "nt":
+ raise SystemExit("real PTY REPL E2E is POSIX-only")
+ if pexpect is None:
+ raise RuntimeError("pexpect is required. Install dependencies with: uv sync --all-extras")
+ self.args = args
+ self.run_dir = run_dir
+ self.cwd = cwd
+ self.env = env
+ self.events: list[dict[str, Any]] = []
+ self.raw_chunks: list[str] = []
+ self.child: Any | None = None
+ self._live_transcript = False
+
+ @property
+ def transcript(self) -> str:
+ return "".join(self.raw_chunks)
+
+ def spawn(self, *, extra_args: list[str] | None = None) -> None:
+ command = [
+ *_split_python_command(self.args.python),
+ "-m",
+ "iac_code.cli.main",
+ "--permission-mode",
+ "bypass_permissions",
+ *(extra_args or []),
+ ]
+ self.events.append({"type": "spawn", "command": command, "cwd": str(self.cwd), "at": _utc_now()})
+ self.child = pexpect.spawn(
+ command[0],
+ command[1:],
+ cwd=str(self.cwd),
+ env=self.env,
+ encoding="utf-8",
+ codec_errors="replace",
+ timeout=self.args.timeout,
+ dimensions=(self.args.terminal_height, self.args.terminal_width),
+ )
+ self.child.logfile_read = _TranscriptCapture(self)
+ self._live_transcript = True
+
+ def sendline(self, text: str) -> None:
+ transcript_offset = len(self.transcript)
+ _sendline_to_child(self._require_child(), text, capture=self._capture_child_output_force)
+ self.events.append(
+ {
+ "type": "sendline",
+ "text": _redact_sensitive_text(text, self.env),
+ "transcript_offset": transcript_offset,
+ "at": _utc_now(),
+ }
+ )
+
+ def send(self, text: str, *, label: str = "send") -> None:
+ transcript_offset = len(self.transcript)
+ self._require_child().send(text)
+ self.events.append(
+ {
+ "type": label,
+ "text": _redact_sensitive_text(text, self.env),
+ "transcript_offset": transcript_offset,
+ "at": _utc_now(),
+ }
+ )
+
+ def paste_image_fixture(self, image_key: str) -> Path:
+ path = _text_image_fixture_path(image_key)
+ transcript_offset = len(self.transcript)
+ child = self._require_child()
+ child.send(f"\x1b[200~{path}\x1b[201~")
+ _drain_child_output(child, capture=self._capture_child_output_force)
+ self.events.append(
+ {
+ "type": "paste-image-fixture",
+ "image_key": image_key,
+ "path": _redact_sensitive_text(str(path), self.env),
+ "transcript_offset": transcript_offset,
+ "at": _utc_now(),
+ }
+ )
+ return path
+
+ def expect_any(self, patterns: tuple[str, ...], *, description: str, timeout: float) -> str:
+ child = self._require_child()
+ deadline = time.monotonic() + timeout
+ all_patterns = list(patterns) + list(PERMISSION_PROMPT_PATTERNS)
+ try:
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(f"timed out waiting for {description}")
+ index = child.expect(all_patterns, timeout=remaining)
+ self._capture_child_output(f"{child.before}{child.after}")
+ if index < len(patterns):
+ matched = patterns[index]
+ self.events.append(
+ {
+ "type": "expect",
+ "description": description,
+ "pattern": matched,
+ "passed": True,
+ "at": _utc_now(),
+ }
+ )
+ return matched
+ matched = PERMISSION_PROMPT_PATTERNS[index - len(patterns)]
+ self.events.append(
+ {
+ "type": "permission_prompt",
+ "description": description,
+ "pattern": matched,
+ "at": _utc_now(),
+ }
+ )
+ self.send(
+ _permission_prompt_response_sequence(self.args.permission_prompt_response),
+ label="permission-prompt-response",
+ )
+ except Exception as exc:
+ self._capture_child_output(str(getattr(child, "before", "") or ""))
+ tail = _compact_text(_normalize_transcript(self.transcript)[-2000:])
+ self.events.append(
+ {
+ "type": "expect",
+ "description": description,
+ "patterns": list(patterns),
+ "passed": False,
+ "error": str(exc),
+ "tail": _redact_sensitive_text(tail, self.env),
+ "at": _utc_now(),
+ }
+ )
+ raise
+
+ def expect_optional(self, patterns: tuple[str, ...], *, description: str, timeout: float) -> bool:
+ child = self._require_child()
+ try:
+ index = child.expect(list(patterns), timeout=timeout)
+ matched = patterns[index]
+ self.events.append(
+ {
+ "type": "expect",
+ "description": description,
+ "pattern": matched,
+ "passed": True,
+ "optional": True,
+ "at": _utc_now(),
+ }
+ )
+ return True
+ except Exception as exc:
+ if pexpect is None or not isinstance(exc, pexpect.TIMEOUT):
+ tail = _compact_text(_normalize_transcript(self.transcript)[-2000:])
+ self.events.append(
+ {
+ "type": "expect",
+ "description": description,
+ "patterns": list(patterns),
+ "passed": False,
+ "optional": True,
+ "error": str(exc),
+ "tail": _redact_sensitive_text(tail, self.env),
+ "at": _utc_now(),
+ }
+ )
+ raise
+ tail = _compact_text(_normalize_transcript(self.transcript)[-2000:])
+ self.events.append(
+ {
+ "type": "expect",
+ "description": description,
+ "patterns": list(patterns),
+ "passed": False,
+ "optional": True,
+ "tail": _redact_sensitive_text(tail, self.env),
+ "at": _utc_now(),
+ }
+ )
+ return False
+
+ def terminate(self, *, force: bool = False) -> None:
+ child = self.child
+ if child is None:
+ return
+ try:
+ if force:
+ child.kill(signal.SIGKILL)
+ else:
+ child.terminate(force=True)
+ finally:
+ self._capture_child_output(str(getattr(child, "before", "") or ""))
+ self.events.append({"type": "terminate", "force": force, "at": _utc_now()})
+
+ def _capture_child_output(self, text: str) -> None:
+ if text and not self._live_transcript:
+ self.raw_chunks.append(text)
+
+ def _capture_child_output_force(self, text: str) -> None:
+ if text:
+ self.raw_chunks.append(text)
+
+ def _require_child(self) -> Any:
+ if self.child is None:
+ raise RuntimeError("REPL child has not been spawned")
+ return self.child
+
+
+class _TranscriptCapture:
+ def __init__(self, pty: ReplPty) -> None:
+ self._pty = pty
+
+ def write(self, text: str) -> None:
+ if text:
+ self._pty.raw_chunks.append(text)
+
+ def flush(self) -> None:
+ return None
+
+
+def _utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _redact_json_value(value: Any, env: dict[str, str]) -> Any:
+ if isinstance(value, str):
+ return _redact_sensitive_text(value, env)
+ if isinstance(value, list):
+ return [_redact_json_value(item, env) for item in value]
+ if isinstance(value, dict):
+ redacted: dict[str, Any] = {}
+ for key, item in value.items():
+ key_text = str(key)
+ upper = key_text.upper()
+ if any(marker in upper for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION")):
+ redacted[key_text] = ""
+ else:
+ redacted[key_text] = _redact_json_value(item, env)
+ return redacted
+ return value
+
+
+def _write_run_artifacts(
+ *,
+ run_dir: Path,
+ env: dict[str, str],
+ raw_transcript: str,
+ events: list[dict[str, Any]],
+ result: ScenarioRunResult,
+) -> None:
+ run_dir.mkdir(parents=True, exist_ok=True)
+ redacted_raw = _redact_sensitive_text(raw_transcript, env)
+ normalized = _normalize_transcript(redacted_raw)
+ (run_dir / "transcript.raw.log").write_text(redacted_raw, encoding="utf-8")
+ (run_dir / "transcript.normalized.log").write_text(normalized, encoding="utf-8")
+ _write_json(run_dir / "child.env.json", _redacted_env_summary(env))
+ with (run_dir / "events.jsonl").open("w", encoding="utf-8") as handle:
+ for event in events:
+ handle.write(json.dumps(_redact_json_value(event, env), ensure_ascii=False, default=str) + "\n")
+ _write_json(run_dir / "summary.json", _redact_json_value(asdict(result), env))
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv)
+ scenarios = _selected_scenarios(args)
+ if args.run_dir and len(scenarios) != 1:
+ raise SystemExit("--run-dir can only be used with a single --scenario")
+ for scenario in scenarios:
+ _validate_scenario_execution(args, scenario)
+ results = [_SCENARIOS[scenario](args, scenario) for scenario in scenarios]
+ return 0 if all(code == 0 for code in results) else 1
+
+
+def _run_with_pty(
+ args: argparse.Namespace,
+ scenario: str,
+ callback: Callable[[ReplPty, dict[str, bool]], None],
+) -> int:
+ started = time.monotonic()
+ run_dir = _scenario_run_dir(args, scenario)
+ workspace_dir = Path(args.cwd).expanduser().resolve() if args.cwd else run_dir / "workspace"
+ workspace_dir.mkdir(parents=True, exist_ok=True)
+ env = _build_child_env(args)
+ pty = ReplPty(args=args, run_dir=run_dir, cwd=workspace_dir, env=env)
+ checks: dict[str, bool] = {}
+ notes: list[str] = []
+ abort_reason = ""
+ passed = False
+ acceptance_applied = False
+ teardown_applied = False
+
+ try:
+ pty.spawn()
+ callback(pty, checks)
+ _apply_acceptance_checks(scenario, args, pty, checks)
+ acceptance_applied = True
+ _teardown_real_cloud_scenario_resources(args=args, scenario=scenario, pty=pty, checks=checks, notes=notes)
+ teardown_applied = True
+ passed = all(checks.values()) if checks else True
+ except BaseException as exc:
+ abort_reason = f"{type(exc).__name__}: {exc}"
+ notes.append(abort_reason)
+ passed = False
+ finally:
+ if not acceptance_applied:
+ try:
+ _apply_acceptance_checks(scenario, args, pty, checks)
+ acceptance_applied = True
+ except BaseException as exc:
+ notes.append(f"acceptance check failed: {type(exc).__name__}: {exc}")
+ if acceptance_applied and not teardown_applied:
+ try:
+ _teardown_real_cloud_scenario_resources(
+ args=args,
+ scenario=scenario,
+ pty=pty,
+ checks=checks,
+ notes=notes,
+ )
+ teardown_applied = True
+ if passed:
+ passed = all(checks.values()) if checks else True
+ except BaseException as exc:
+ notes.append(f"final teardown failed: {type(exc).__name__}: {exc}")
+ if passed:
+ passed = False
+ if not args.leave_running:
+ try:
+ pty.terminate()
+ except BaseException as exc:
+ notes.append(f"terminal child termination failed: {type(exc).__name__}: {exc}")
+ if passed:
+ passed = False
+ result = ScenarioRunResult(
+ scenario=scenario,
+ run_dir=str(run_dir),
+ passed=passed,
+ checks=checks,
+ elapsed_seconds=round(time.monotonic() - started, 3),
+ abort_reason=abort_reason,
+ notes=notes,
+ )
+ _write_run_artifacts(run_dir=run_dir, env=env, raw_transcript=pty.transcript, events=pty.events, result=result)
+ _print_result(result)
+
+ return 0 if passed else 1
+
+
+def _print_result(result: ScenarioRunResult) -> None:
+ print(f"\nREPL pipeline scenario: {result.scenario}")
+ print(f"run_dir: {result.run_dir}")
+ if result.abort_reason:
+ print(f"abort_reason: {_compact_text(result.abort_reason, max_chars=1000)}")
+ if result.notes:
+ print("\nnotes:")
+ for note in result.notes:
+ print(f" - {_compact_text(note, max_chars=1000)}")
+ print("\nchecks:")
+ for name, passed in result.checks.items():
+ print(f" {'OK' if passed else 'FAIL'} {name}")
+ print(f"\nRESULT: {'PASS' if result.passed else 'FAIL'}")
+
+
+def _has_any_pattern(text: str, patterns: tuple[str, ...]) -> bool:
+ return any(re.search(pattern, text) for pattern in patterns)
+
+
+def _count_pattern(text: str, patterns: tuple[str, ...]) -> int:
+ return sum(len(re.findall(pattern, text)) for pattern in patterns)
+
+
+def _resume_spawns(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return [
+ event
+ for event in events
+ if event.get("type") == "spawn" and "--continue" in [str(item) for item in event.get("command", [])]
+ ]
+
+
+def _event_index(events: list[dict[str, Any]], event_type: str) -> int | None:
+ for index, event in enumerate(events):
+ if event.get("type") == event_type:
+ return index
+ return None
+
+
+def _event_before(events: list[dict[str, Any]], before_type: str, after_type: str) -> bool:
+ before = _event_index(events, before_type)
+ after = _event_index(events, after_type)
+ return before is not None and after is not None and before < after
+
+
+def _has_sendline_event(events: list[dict[str, Any]], text: str) -> bool:
+ return any(event.get("type") == "sendline" and event.get("text") == text for event in events)
+
+
+def _has_image_fixture_event(events: list[dict[str, Any]], image_key: str) -> bool:
+ return any(event.get("type") == "paste-image-fixture" and event.get("image_key") == image_key for event in events)
+
+
+def _has_vswitch_business_evidence(transcript: str) -> bool:
+ if _has_any_pattern(transcript, VSWITCH_EVIDENCE_PATTERNS):
+ return True
+ has_vswitch_text = bool(re.search(r"(?i)VSwitch|交换机", transcript))
+ has_deploy_result = bool(re.search(r"Stack ID|Stack 名称|CREATE_COMPLETE|部署成功", transcript))
+ return has_vswitch_text and has_deploy_result
+
+
+def _has_vswitch_answer_evidence(text: str) -> bool:
+ return _has_any_pattern(text, VSWITCH_MENTION_PATTERNS)
+
+
+def _has_security_group_target_evidence(text: str) -> bool:
+ return _has_any_pattern(text, SECURITY_GROUP_EVIDENCE_PATTERNS + SECURITY_GROUP_MENTION_PATTERNS)
+
+
+def _has_positive_vswitch_target_evidence(text: str) -> bool:
+ cleaned_text = text
+ for pattern in NEGATED_VSWITCH_TARGET_SPAN_PATTERNS:
+ cleaned_text = re.sub(pattern, "", cleaned_text)
+ positive_context_lines = [
+ line for line in cleaned_text.splitlines() if not _has_any_pattern(line, NEGATED_VSWITCH_TARGET_LINE_PATTERNS)
+ ]
+ return _has_any_pattern("\n".join(positive_context_lines), POSITIVE_VSWITCH_TARGET_PATTERNS)
+
+
+def _last_event_suffix(
+ transcript: str,
+ events: list[dict[str, Any]],
+ *,
+ event_type: str,
+ text: str | None = None,
+) -> str:
+ offset: int | None = None
+ for event in events:
+ if event.get("type") != event_type:
+ continue
+ if text is not None and event.get("text") != text:
+ continue
+ raw_offset = event.get("transcript_offset")
+ if isinstance(raw_offset, int) and raw_offset >= 0:
+ offset = raw_offset
+ if offset is None:
+ return ""
+ return transcript[offset:]
+
+
+def _suffix_after_sendline_text(
+ transcript: str,
+ events: list[dict[str, Any]],
+ text: str,
+) -> str:
+ suffix = _normalize_transcript(_last_event_suffix(transcript, events, event_type="sendline", text=text))
+ normalized_text = _normalize_transcript(text)
+ if normalized_text and normalized_text in suffix:
+ return suffix.split(normalized_text, 1)[1]
+ return suffix
+
+
+def _suffix_after_image_fixture(
+ transcript: str,
+ events: list[dict[str, Any]],
+ image_key: str,
+) -> str:
+ offset: int | None = None
+ for event in events:
+ if event.get("type") != "paste-image-fixture" or event.get("image_key") != image_key:
+ continue
+ raw_offset = event.get("transcript_offset")
+ if isinstance(raw_offset, int) and raw_offset >= 0:
+ offset = raw_offset
+ if offset is None:
+ return ""
+ return _normalize_transcript(transcript[offset:])
+
+
+def _add_acceptance_check(checks: dict[str, bool], name: str, passed: bool) -> None:
+ checks[f"acceptance: {name}"] = bool(passed)
+
+
+def _cleanup_stack_name(run_dir: Path, label: str) -> str:
+ suffix = Path(run_dir).name.rsplit("-", maxsplit=1)[-1] or "stack"
+ safe_label = "".join(ch if ch.isalnum() else "-" for ch in label.lower()).strip("-") or "stack"
+ return f"iac-e2e-{suffix[:12]}-{safe_label}"[:128]
+
+
+def _scenario_stack_name(run_dir: Path, scenario: str) -> str:
+ suffix = Path(run_dir).name.rsplit("-", maxsplit=1)[-1] or "stack"
+ safe_scenario = "".join(ch if ch.isalnum() else "-" for ch in scenario.lower()).strip("-") or "scenario"
+ return f"iac-e2e-{suffix[:12]}-{safe_scenario}"[:128]
+
+
+def _stack_name_constraint(run_dir: Path, scenario: str) -> str:
+ stack_name = _scenario_stack_name(run_dir, scenario)
+ return f"本次 CreateStack 的 params.StackName 必须精确等于 `{stack_name}`,禁止使用默认或自动生成 StackName。"
+
+
+def _stack_creating_prompt(text: str, run_dir: Path, scenario: str) -> str:
+ return f"{text}。{_stack_name_constraint(run_dir, scenario)}"
+
+
+def _text_image_fixture_path(image_key: str) -> Path:
+ filename = TEXT_IMAGE_FIXTURE_FILENAMES.get(image_key)
+ if not filename:
+ raise KeyError(f"unknown text image fixture: {image_key}")
+ path = (TEXT_IMAGE_FIXTURE_ROOT / filename).resolve()
+ if not path.is_file():
+ raise FileNotFoundError(f"text image fixture not found: {path}")
+ return path
+
+
+def _submit_image_fixture(pty: ReplPty, image_key: str, *, caption: str = "") -> None:
+ pty.paste_image_fixture(image_key)
+ if caption:
+ pty.sendline(caption)
+ else:
+ pty.send("\r", label="submit-image")
+
+
+def _cleanup_network_target_from_args(args: argparse.Namespace) -> CleanupNetworkTarget | None:
+ if not (
+ args.cleanup_vpc_id
+ and args.cleanup_zone_id
+ and args.cleanup_vswitch_cidr
+ and args.cleanup_rollback_vswitch_cidr
+ ):
+ return None
+ return CleanupNetworkTarget(
+ vpc_id=args.cleanup_vpc_id,
+ vpc_cidr=args.cleanup_vpc_cidr,
+ zone_id=args.cleanup_zone_id,
+ vswitch_cidr=args.cleanup_vswitch_cidr,
+ rollback_vswitch_cidr=args.cleanup_rollback_vswitch_cidr,
+ )
+
+
+def _cleanup_network_prompt_fragment(args: argparse.Namespace, *, rollback: bool) -> str:
+ target = _cleanup_network_target_from_args(args)
+ if target is None:
+ return (
+ "必须先读取所选 VPC 的 CIDR,并选择属于该 VPC CIDR 的未占用 VSwitch CIDR;"
+ "第一次和回退后的第二次部署必须使用两个不同的合法未占用 VSwitch CIDR。"
+ )
+
+ vpc_cidr = f"(CIDR `{target.vpc_cidr}`)" if target.vpc_cidr else ""
+ if rollback:
+ return (
+ f"固定使用已有 VPC `{target.vpc_id}`{vpc_cidr}、可用区 `{target.zone_id}`;"
+ f"本次重新部署只创建安全组,CreateStack 模板参数必须显式设置 VpcId=`{target.vpc_id}`。"
+ "禁止创建 VSwitch,禁止在第二个栈中使用 CidrBlock 或模板默认 CidrBlock。"
+ )
+
+ return (
+ f"固定使用已有 VPC `{target.vpc_id}`{vpc_cidr}、可用区 `{target.zone_id}`、"
+ f"首个 VSwitch CIDR `{target.vswitch_cidr}`;首次 CreateStack 模板参数必须显式设置 "
+ f"VpcId=`{target.vpc_id}`、ZoneId=`{target.zone_id}`、CidrBlock=`{target.vswitch_cidr}`。"
+ "禁止使用模板默认 CidrBlock。"
+ )
+
+
+def _cleanup_pipeline_prompt(args: argparse.Namespace, run_dir: Path) -> str:
+ first_stack_name = _cleanup_stack_name(run_dir, "first")
+ return (
+ f"{args.initial_prompt}。第一次 CreateStack 的 params.StackName 必须精确等于 `{first_stack_name}`,"
+ "禁止使用模板名、候选方案名或 vswitch-in-existing-vpc,也不能复用已有资源栈。"
+ f"{_cleanup_network_prompt_fragment(args, rollback=False)}"
+ )
+
+
+def _cleanup_rollback_prompt(args: argparse.Namespace, run_dir: Path) -> str:
+ second_stack_name = _cleanup_stack_name(run_dir, "second")
+ return (
+ f"{args.rollback_prompt}。重新部署时 CreateStack 的 params.StackName 必须精确等于 `{second_stack_name}`,"
+ "禁止使用模板名、候选方案名或 vswitch-in-existing-vpc,也不能复用已有资源栈。"
+ "本次回退后的新方案只创建安全组,不创建 VSwitch。"
+ f"{_cleanup_network_prompt_fragment(args, rollback=True)}"
+ )
+
+
+async def _call_aliyun_api_async(product: str, action: str, params: dict[str, Any]) -> dict[str, Any]:
+ from iac_code.tools.base import ToolContext
+ from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi
+
+ result = await AliyunApi().execute(
+ tool_input={"product": product, "action": action, "params": params},
+ context=ToolContext(),
+ )
+ if result.is_error:
+ raise RuntimeError(_compact_text(result.content, max_chars=1000))
+ body = json.loads(result.content)
+ return body if isinstance(body, dict) else {}
+
+
+def _call_aliyun_api(product: str, action: str, params: dict[str, Any]) -> dict[str, Any]:
+ return asyncio.run(_call_aliyun_api_async(product, action, params))
+
+
+def _nested_api_items(data: dict[str, Any], outer_key: str, inner_key: str) -> list[dict[str, Any]]:
+ outer = data.get(outer_key)
+ if isinstance(outer, dict):
+ items = outer.get(inner_key) or outer.get(inner_key.lower()) or []
+ else:
+ items = outer or []
+ return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
+
+
+def _find_available_vswitch_cidrs(vpc_cidr: str, used_cidrs: Iterable[str], *, count: int) -> list[str]:
+ try:
+ vpc_network = ipaddress.ip_network(vpc_cidr, strict=False)
+ except ValueError:
+ return []
+ if not isinstance(vpc_network, ipaddress.IPv4Network):
+ return []
+
+ used_networks: list[ipaddress.IPv4Network] = []
+ for cidr in used_cidrs:
+ try:
+ network = ipaddress.ip_network(cidr, strict=False)
+ except ValueError:
+ continue
+ if isinstance(network, ipaddress.IPv4Network):
+ used_networks.append(network)
+
+ prefixlen = max(24, vpc_network.prefixlen)
+ available: list[str] = []
+ if prefixlen == vpc_network.prefixlen:
+ if not any(vpc_network.overlaps(used) for used in used_networks):
+ available.append(str(vpc_network))
+ return available
+
+ for subnet in reversed(list(vpc_network.subnets(new_prefix=prefixlen))):
+ if not any(subnet.overlaps(used) for used in used_networks):
+ available.append(str(subnet))
+ used_networks.append(subnet)
+ if len(available) >= count:
+ return available
+ return available
+
+
+def _find_available_vswitch_cidr(vpc_cidr: str, used_cidrs: Iterable[str]) -> str | None:
+ cidrs = _find_available_vswitch_cidrs(vpc_cidr, used_cidrs, count=1)
+ return cidrs[0] if cidrs else None
+
+
+def _discover_cleanup_network_target() -> CleanupNetworkTarget:
+ vpcs_data = _call_aliyun_api("vpc", "DescribeVpcs", {"PageSize": 50})
+ for vpc in _nested_api_items(vpcs_data, "Vpcs", "Vpc"):
+ vpc_id = str(vpc.get("VpcId") or "")
+ vpc_cidr = str(vpc.get("CidrBlock") or "")
+ if not vpc_id or not vpc_cidr or str(vpc.get("Status") or "") != "Available":
+ continue
+
+ vswitches_data = _call_aliyun_api("vpc", "DescribeVSwitches", {"VpcId": vpc_id, "PageSize": 50})
+ vswitches = _nested_api_items(vswitches_data, "VSwitches", "VSwitch")
+ zone_ids = [str(item.get("ZoneId") or "") for item in vswitches if str(item.get("ZoneId") or "")]
+ used_cidrs = [str(item.get("CidrBlock") or "") for item in vswitches if str(item.get("CidrBlock") or "")]
+ vswitch_cidrs = _find_available_vswitch_cidrs(vpc_cidr, used_cidrs, count=2)
+ if zone_ids and len(vswitch_cidrs) >= 2:
+ return CleanupNetworkTarget(
+ vpc_id=vpc_id,
+ vpc_cidr=vpc_cidr,
+ zone_id=zone_ids[0],
+ vswitch_cidr=vswitch_cidrs[0],
+ rollback_vswitch_cidr=vswitch_cidrs[1],
+ )
+
+ raise RuntimeError("No available existing VPC with a free VSwitch CIDR was found for cleanup E2E.")
+
+
+def _ensure_cleanup_network_target(args: argparse.Namespace, run_dir: Path) -> CleanupNetworkTarget:
+ target = _cleanup_network_target_from_args(args)
+ if target is None:
+ target = _discover_cleanup_network_target()
+ args.cleanup_vpc_id = target.vpc_id
+ args.cleanup_vpc_cidr = target.vpc_cidr
+ args.cleanup_zone_id = target.zone_id
+ args.cleanup_vswitch_cidr = target.vswitch_cidr
+ args.cleanup_rollback_vswitch_cidr = target.rollback_vswitch_cidr
+ _write_json(Path(run_dir) / "cleanup-network-target.json", asdict(target))
+ return target
+
+
+def _session_id_from_transcript(transcript: str) -> str | None:
+ patterns = (
+ r"\bSession:\s*([0-9a-fA-F][0-9a-fA-F-]{7,})",
+ r"\bsession_id[\"'\s:=]+([0-9a-fA-F][0-9a-fA-F-]{7,})",
+ r"\bsession[\"'\s:=]+([0-9a-fA-F][0-9a-fA-F-]{7,})",
+ )
+ for pattern in patterns:
+ match = re.search(pattern, transcript)
+ if match:
+ return match.group(1)
+ return None
+
+
+def _cleanup_ledger_path(pty: Any) -> Path | None:
+ explicit = getattr(pty, "cleanup_ledger_path", None)
+ if explicit:
+ return Path(explicit)
+
+ cwd = str(getattr(pty, "cwd", "") or "")
+ if not cwd:
+ return None
+ session_id = str(getattr(pty, "session_id", "") or "") or _session_id_from_transcript(
+ str(getattr(pty, "transcript", "") or "")
+ )
+ try:
+ from iac_code.services.session_storage import SessionStorage
+
+ storage = SessionStorage()
+ if session_id:
+ return Path(storage.session_dir(cwd, session_id)) / "pipeline" / "cleanup.yaml"
+
+ project_dir_for = getattr(storage, "_project_dir_for", None)
+ if callable(project_dir_for):
+ project_dir = Path(project_dir_for(cwd))
+ candidates = sorted(
+ project_dir.glob("*/pipeline/cleanup.yaml"),
+ key=lambda path: path.stat().st_mtime if path.exists() else 0,
+ reverse=True,
+ )
+ if candidates:
+ return candidates[0]
+ except Exception:
+ return None
+ return None
+
+
+def _cleanup_ledger_data(pty: Any) -> dict[str, Any]:
+ inline = getattr(pty, "cleanup_ledger", None)
+ if isinstance(inline, dict):
+ return inline
+ path = _cleanup_ledger_path(pty)
+ if path is None or yaml is None or not path.exists():
+ return {}
+ try:
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeDecodeError, Exception):
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _cleanup_ledger_items(pty: Any, key: str) -> list[dict[str, Any]]:
+ values = _cleanup_ledger_data(pty).get(key)
+ return [item for item in values if isinstance(item, dict)] if isinstance(values, list) else []
+
+
+def _is_ros_stack_resource(resource: dict[str, Any]) -> bool:
+ provider = str(resource.get("provider") or "").lower()
+ resource_type = str(resource.get("resource_type") or resource.get("resourceType") or "").lower()
+ return provider == "ros" and resource_type == "stack"
+
+
+def _string_from_mapping(mapping: Any, *keys: str) -> str | None:
+ if not isinstance(mapping, dict):
+ return None
+ for key in keys:
+ value = mapping.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
+def _unique_strings(values: Iterable[str | None]) -> list[str]:
+ result: list[str] = []
+ seen: set[str] = set()
+ for value in values:
+ if not isinstance(value, str) or not value or value in seen:
+ continue
+ seen.add(value)
+ result.append(value)
+ return result
+
+
+def _latest_observed_stack_id(pty: Any, *, exclude: set[str]) -> str | None:
+ resources = _cleanup_ledger_items(pty, "observed_resources")
+ for resource in reversed(resources):
+ if not _is_ros_stack_resource(resource):
+ continue
+ action = str(resource.get("observed_action") or resource.get("observedAction") or resource.get("action") or "")
+ if action and action != "CreateStack":
+ continue
+ stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ if stack_id and stack_id not in exclude:
+ return stack_id
+ return None
+
+
+def _is_create_stack_observation(resource: dict[str, Any]) -> bool:
+ action = str(resource.get("observed_action") or resource.get("observedAction") or resource.get("action") or "")
+ return not action or action == "CreateStack"
+
+
+def _observed_create_stack_resources(pty: Any) -> list[dict[str, Any]]:
+ resources: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for resource in _cleanup_ledger_items(pty, "observed_resources"):
+ if not _is_ros_stack_resource(resource) or not _is_create_stack_observation(resource):
+ continue
+ stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ if not stack_id or stack_id in seen:
+ continue
+ seen.add(stack_id)
+ resources.append(resource)
+ return resources
+
+
+def _observed_create_stack_ids(pty: Any) -> list[str]:
+ return _unique_strings(
+ _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ for resource in _observed_create_stack_resources(pty)
+ )
+
+
+def _observed_create_stack_names(pty: Any) -> list[str]:
+ return _unique_strings(
+ _string_from_mapping(resource, "resource_name", "resourceName", "stack_name", "stackName")
+ for resource in _observed_create_stack_resources(pty)
+ )
+
+
+def _wait_for_latest_observed_stack_id(pty: Any, *, exclude: set[str], timeout: float) -> str:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ stack_id = _latest_observed_stack_id(pty, exclude=exclude)
+ if stack_id:
+ return stack_id
+ time.sleep(0.5)
+ raise TimeoutError("Timed out waiting for rollback cleanup ledger to observe a ROS stack")
+
+
+def _cleanup_target_stack_ids(pty: Any, *, exclude: set[str]) -> list[str]:
+ stack_ids: list[str] = []
+ for resource in _cleanup_ledger_items(pty, "cleanup_resources"):
+ if not _is_ros_stack_resource(resource):
+ continue
+ if resource.get("cleanup_required") is False or resource.get("cleanupRequired") is False:
+ continue
+ stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ if stack_id and stack_id not in exclude:
+ stack_ids.append(stack_id)
+ return _unique_strings(stack_ids)
+
+
+def _wait_for_cleanup_target_stack_ids(pty: Any, *, exclude: set[str], timeout: float) -> list[str]:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ stack_ids = _cleanup_target_stack_ids(pty, exclude=exclude)
+ if stack_ids:
+ return stack_ids
+ time.sleep(0.5)
+ raise TimeoutError("Timed out waiting for rollback cleanup ledger to record target stacks")
+
+
+def _cleanup_resource_for_stack(pty: Any, stack_id: str | None) -> dict[str, Any] | None:
+ if not stack_id:
+ return None
+ for resource in _cleanup_ledger_items(pty, "cleanup_resources"):
+ if _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") == stack_id:
+ return resource
+ return None
+
+
+def _cleanup_resource_completed(resource: dict[str, Any] | None) -> bool:
+ if not isinstance(resource, dict):
+ return False
+ cleanup_status = resource.get("cleanupStatus") or resource.get("cleanup_status") or resource.get("status")
+ stack_status = resource.get("stackStatus") or resource.get("progressStatus") or resource.get("progress_status")
+ return cleanup_status == "completed" and stack_status == "DELETE_COMPLETE"
+
+
+def _wait_for_cleanup_resource_status(pty: Any, stack_id: str, statuses: set[str], *, timeout: float) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ resource = _cleanup_resource_for_stack(pty, stack_id)
+ status = ""
+ if isinstance(resource, dict):
+ status = str(
+ resource.get("cleanup_status") or resource.get("cleanupStatus") or resource.get("status") or ""
+ )
+ if status in statuses:
+ return
+ time.sleep(0.5)
+ raise TimeoutError(f"Timed out waiting for cleanup ledger status {sorted(statuses)} on {stack_id}")
+
+
+def _cleanup_history_has_event(pty: Any, stack_id: str | None, event_types: set[str]) -> bool:
+ if not stack_id:
+ return False
+ for item in _cleanup_ledger_items(pty, "history"):
+ event_type = str(item.get("type") or item.get("event_type") or item.get("eventType") or "")
+ if event_type not in event_types:
+ continue
+ resource = item.get("resource")
+ resource_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ resource_id = resource_id or _string_from_mapping(item, "resource_id", "resourceId", "stack_id", "stackId")
+ if resource_id == stack_id:
+ return True
+ return False
+
+
+def _capture_ros_stack_states(pty: Any, stack_ids: Iterable[str], name: str) -> dict[str, dict[str, Any]]:
+ existing = getattr(pty, "ros_stack_states", None)
+ states: dict[str, dict[str, Any]] = {}
+ if isinstance(existing, dict):
+ states.update({str(key): value for key, value in existing.items() if isinstance(value, dict)})
+
+ missing = [stack_id for stack_id in _unique_strings(stack_ids) if stack_id not in states]
+ for stack_id in missing:
+ region_id = _region_for_stack(pty, stack_id)
+ states[stack_id] = _get_ros_stack_state(
+ stack_id=stack_id,
+ region_id=region_id,
+ redaction_env=getattr(pty, "env", {}),
+ )
+
+ run_dir = getattr(pty, "run_dir", None)
+ env = getattr(pty, "env", {})
+ if run_dir is not None:
+ _write_json(
+ Path(run_dir) / f"{name}.ros-stack-states.json",
+ _redact_json_value(states, env if isinstance(env, dict) else {}),
+ )
+ return states
+
+
+def _fresh_ros_stack_state(pty: Any, stack_id: str) -> dict[str, Any]:
+ return _get_ros_stack_state(
+ stack_id=stack_id,
+ region_id=_region_for_stack(pty, stack_id),
+ redaction_env=getattr(pty, "env", {}),
+ )
+
+
+def _get_ros_stack_state(
+ *,
+ stack_id: str,
+ region_id: str,
+ redaction_env: dict[str, str] | None,
+) -> dict[str, Any]:
+ try:
+ from alibabacloud_ros20190910 import models as ros_models
+
+ from iac_code.services.cloud_credentials import CloudCredentials
+ from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory
+
+ credential = CloudCredentials().get_provider("aliyun")
+ effective_region = region_id or (credential.region_id if credential is not None else "")
+ client = RosClientFactory.create(credential, effective_region)
+ request = ros_models.GetStackRequest(stack_id=stack_id, region_id=effective_region)
+ response = client.get_stack(request)
+ body = response.body.to_map()
+ return {
+ "stack_id": str(body.get("StackId") or stack_id),
+ "stack_name": str(body.get("StackName") or ""),
+ "region_id": effective_region,
+ "status": str(body.get("Status") or ""),
+ "status_reason": str(body.get("StatusReason") or ""),
+ "not_found": False,
+ }
+ except Exception as exc:
+ message = _redact_sensitive_text(str(exc), redaction_env)
+ return {
+ "stack_id": stack_id,
+ "region_id": region_id,
+ "status": "",
+ "not_found": _is_ros_stack_not_found(exc),
+ "error": _compact_text(message, max_chars=1000),
+ }
+
+
+def _delete_ros_stack(
+ *,
+ stack_id: str,
+ region_id: str,
+ redaction_env: dict[str, str] | None,
+) -> None:
+ try:
+ from alibabacloud_ros20190910 import models as ros_models
+
+ from iac_code.services.cloud_credentials import CloudCredentials
+ from iac_code.tools.cloud.aliyun.ros_client import RosClientFactory
+
+ credential = CloudCredentials().get_provider("aliyun")
+ effective_region = region_id or (credential.region_id if credential is not None else "")
+ client = RosClientFactory.create(credential, effective_region)
+ request = ros_models.DeleteStackRequest(stack_id=stack_id, region_id=effective_region)
+ client.delete_stack(request)
+ except Exception as exc:
+ if _is_ros_stack_not_found(exc):
+ return
+ message = _redact_sensitive_text(str(exc), redaction_env)
+ raise RuntimeError(_compact_text(message, max_chars=1000)) from exc
+
+
+def _wait_for_ros_stack_deleted(
+ *,
+ pty: Any,
+ stack_id: str,
+ timeout: float,
+) -> dict[str, Any]:
+ deadline = time.monotonic() + timeout
+ last_state: dict[str, Any] = {}
+ while time.monotonic() < deadline:
+ last_state = _fresh_ros_stack_state(pty, stack_id)
+ if _ros_stack_deleted(last_state):
+ return last_state
+ time.sleep(5)
+ status = last_state.get("status") or ""
+ raise TimeoutError(f"Timed out waiting for ROS stack deletion: {stack_id} ({status})")
+
+
+def _is_ros_stack_not_found(exc: BaseException) -> bool:
+ code = str(getattr(exc, "code", "") or "")
+ message = str(exc)
+ combined = f"{code} {message}".lower()
+ not_found_tokens = (
+ "stacknotfound",
+ "notfound.stack",
+ "entitynotexist.stack",
+ "specified stack does not exist",
+ "stack could not be found",
+ "stack not found",
+ )
+ return any(token in combined for token in not_found_tokens)
+
+
+def _region_for_stack(pty: Any, stack_id: str) -> str:
+ for key in ("cleanup_resources", "observed_resources"):
+ for resource in reversed(_cleanup_ledger_items(pty, key)):
+ if _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId") == stack_id:
+ region = _string_from_mapping(resource, "region_id", "regionId", "RegionId")
+ if region:
+ return region
+ env = getattr(pty, "env", {})
+ return env.get("ALIBABA_CLOUD_REGION_ID", "") if isinstance(env, dict) else ""
+
+
+def _ros_stack_deleted(state: dict[str, Any]) -> bool:
+ if not isinstance(state, dict):
+ return False
+ if state.get("not_found") is True:
+ return True
+ return state.get("status") in ROS_STACK_DELETED_STATUSES
+
+
+def _ros_stack_retained(state: dict[str, Any]) -> bool:
+ if not isinstance(state, dict) or state.get("not_found") is True:
+ return False
+ status = state.get("status")
+ return isinstance(status, str) and bool(status) and not status.startswith("DELETE_")
+
+
+def _ros_stack_states_for_acceptance(pty: Any, stack_ids: Iterable[str], name: str) -> dict[str, dict[str, Any]]:
+ return _capture_ros_stack_states(pty, _unique_strings(stack_ids), name)
+
+
+def _apply_cleanup_acceptance_checks(
+ *,
+ scenario: str,
+ transcript: str,
+ events: list[dict[str, Any]],
+ pty: Any,
+ checks: dict[str, bool],
+) -> None:
+ first_stack_id = str(getattr(pty, "cleanup_first_stack_id", "") or "")
+ second_stack_id = str(getattr(pty, "cleanup_second_stack_id", "") or "")
+ observed_stack_ids = {
+ stack_id
+ for stack_id in (
+ _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ for resource in _cleanup_ledger_items(pty, "observed_resources")
+ if _is_ros_stack_resource(resource)
+ )
+ if stack_id
+ }
+ cleanup_stack_ids = _cleanup_target_stack_ids(pty, exclude={stack_id for stack_id in [second_stack_id] if stack_id})
+ run_dir = Path(getattr(pty, "run_dir", ""))
+ expected_first_stack_name = _cleanup_stack_name(run_dir, "first")
+ expected_second_stack_name = _cleanup_stack_name(run_dir, "second")
+
+ _add_acceptance_check(
+ checks,
+ "first rollback stack observed",
+ bool(first_stack_id) and first_stack_id in observed_stack_ids,
+ )
+ _add_acceptance_check(
+ checks,
+ "rollback cleanup ledger includes first stack",
+ bool(first_stack_id) and first_stack_id in cleanup_stack_ids,
+ )
+ _add_acceptance_check(checks, "rollback cleanup target stacks observed", bool(cleanup_stack_ids))
+ _add_acceptance_check(
+ checks,
+ "second stack created after rollback",
+ bool(second_stack_id) and second_stack_id != first_stack_id and second_stack_id in observed_stack_ids,
+ )
+ _add_acceptance_check(
+ checks,
+ "first rollback stack name matches test stack",
+ bool(first_stack_id) and _observed_cleanup_stack_name(pty, first_stack_id) == expected_first_stack_name,
+ )
+ _add_acceptance_check(
+ checks,
+ "second stack name matches test stack",
+ bool(second_stack_id) and _observed_cleanup_stack_name(pty, second_stack_id) == expected_second_stack_name,
+ )
+ _add_acceptance_check(
+ checks,
+ "cleanup snapshot does not target second stack",
+ bool(second_stack_id) and _cleanup_resource_for_stack(pty, second_stack_id) is None,
+ )
+ _add_acceptance_check(
+ checks,
+ "rollback cleanup completed",
+ bool(cleanup_stack_ids)
+ and all(
+ _cleanup_resource_completed(_cleanup_resource_for_stack(pty, stack_id)) for stack_id in cleanup_stack_ids
+ ),
+ )
+ _add_acceptance_check(
+ checks,
+ "no ROS create failure in cleanup transcript",
+ not _has_any_pattern(transcript, CLEANUP_DEPLOYMENT_FAILURE_PATTERNS),
+ )
+
+ ros_states = _ros_stack_states_for_acceptance(
+ pty,
+ [*cleanup_stack_ids, second_stack_id],
+ "acceptance-after-cleanup",
+ )
+ _add_acceptance_check(
+ checks,
+ "ROS first rollback stack deleted",
+ bool(first_stack_id) and _ros_stack_deleted(ros_states.get(first_stack_id, {})),
+ )
+ _add_acceptance_check(
+ checks,
+ "ROS rollback cleanup stacks deleted",
+ bool(cleanup_stack_ids)
+ and all(_ros_stack_deleted(ros_states.get(stack_id, {})) for stack_id in cleanup_stack_ids),
+ )
+ _add_acceptance_check(
+ checks,
+ "ROS second stack retained",
+ bool(second_stack_id) and _ros_stack_retained(ros_states.get(second_stack_id, {})),
+ )
+
+ if scenario == "rollback-step5-cleanup-recovery":
+ _add_acceptance_check(
+ checks,
+ "cleanup process was killed",
+ any(event.get("type") == "terminate" and event.get("force") is True for event in events),
+ )
+ _add_acceptance_check(checks, "cleanup resume used --continue", bool(_resume_spawns(events)))
+ _add_acceptance_check(
+ checks,
+ "cleanup retriggered after restart",
+ bool(_resume_spawns(events))
+ and _cleanup_history_has_event(
+ pty,
+ first_stack_id,
+ {"cleanup_started", "cleanup_progress", "cleanup_completed"},
+ ),
+ )
+
+
+def _owned_cleanup_stack_names(run_dir: Path) -> set[str]:
+ return {_cleanup_stack_name(run_dir, "first"), _cleanup_stack_name(run_dir, "second")}
+
+
+def _observed_cleanup_stack_ids(pty: Any) -> list[str]:
+ stack_ids = [
+ str(getattr(pty, "cleanup_first_stack_id", "") or ""),
+ str(getattr(pty, "cleanup_second_stack_id", "") or ""),
+ ]
+ stack_ids.extend(
+ _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ for resource in _cleanup_ledger_items(pty, "observed_resources")
+ if _is_ros_stack_resource(resource)
+ )
+ return _unique_strings(stack_ids)
+
+
+def _observed_cleanup_stack_name(pty: Any, stack_id: str) -> str:
+ for resource in reversed(_cleanup_ledger_items(pty, "observed_resources")):
+ if not _is_ros_stack_resource(resource):
+ continue
+ resource_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ if resource_id != stack_id:
+ continue
+ return _string_from_mapping(resource, "resource_name", "resourceName", "stack_name", "stackName")
+ return ""
+
+
+def _apply_stack_creating_acceptance_checks(scenario: str, pty: Any, checks: dict[str, bool]) -> None:
+ if scenario not in STACK_CREATING_SCENARIOS:
+ return
+ stack_ids = _observed_create_stack_ids(pty)
+ expected_stack_name = _scenario_stack_name(Path(getattr(pty, "run_dir", "")), scenario)
+ stack_names = _observed_create_stack_names(pty)
+ _add_acceptance_check(checks, "ROS stack observed in cleanup ledger", bool(stack_ids))
+ _add_acceptance_check(
+ checks,
+ "ROS stack name is test-owned",
+ bool(stack_ids) and expected_stack_name in stack_names,
+ )
+ ros_states = _ros_stack_states_for_acceptance(pty, stack_ids, "acceptance-before-teardown") if stack_ids else {}
+ _add_acceptance_check(
+ checks,
+ "ROS created stack retained before teardown",
+ bool(stack_ids) and any(_ros_stack_retained(ros_states.get(stack_id, {})) for stack_id in stack_ids),
+ )
+
+
+def _teardown_cleanup_scenario_resources(
+ *,
+ args: argparse.Namespace,
+ scenario: str,
+ pty: Any,
+ checks: dict[str, bool],
+ notes: list[str],
+) -> None:
+ if scenario not in {"rollback-step5-cleanup", "rollback-step5-cleanup-recovery"}:
+ return
+ if args.skip_final_teardown:
+ notes.append("final teardown skipped by --skip-final-teardown")
+ return
+
+ run_dir = Path(getattr(pty, "run_dir", ""))
+ owned_stack_names = _owned_cleanup_stack_names(run_dir)
+ stack_ids = _observed_cleanup_stack_ids(pty)
+ if not stack_ids:
+ checks["teardown: no cleanup scenario stacks leaked"] = True
+ return
+
+ deletion_failures: list[str] = []
+ deleted_stack_ids: list[str] = []
+ for stack_id in stack_ids:
+ state = _fresh_ros_stack_state(pty, stack_id)
+ if _ros_stack_deleted(state):
+ continue
+
+ stack_name = str(state.get("stack_name") or "")
+ if stack_name not in owned_stack_names:
+ deletion_failures.append(
+ f"{stack_id} has unexpected stack name {stack_name or ''}; "
+ f"expected one of {sorted(owned_stack_names)}"
+ )
+ continue
+
+ try:
+ _delete_ros_stack(
+ stack_id=stack_id,
+ region_id=str(state.get("region_id") or _region_for_stack(pty, stack_id)),
+ redaction_env=getattr(pty, "env", {}),
+ )
+ final_state = _wait_for_ros_stack_deleted(pty=pty, stack_id=stack_id, timeout=args.final_teardown_timeout)
+ if _ros_stack_deleted(final_state):
+ deleted_stack_ids.append(stack_id)
+ else:
+ deletion_failures.append(f"{stack_id} final status is {final_state.get('status') or ''}")
+ except Exception as exc:
+ deletion_failures.append(f"{stack_id}: {type(exc).__name__}: {exc}")
+
+ for failure in deletion_failures:
+ notes.append(f"final teardown failed: {_compact_text(failure, max_chars=1000)}")
+
+ checks["teardown: cleanup scenario owned ROS stacks deleted"] = not deletion_failures
+ if deleted_stack_ids:
+ notes.append(f"final teardown deleted ROS stacks: {', '.join(deleted_stack_ids)}")
+
+
+def _teardown_real_cloud_scenario_resources(
+ *,
+ args: argparse.Namespace,
+ scenario: str,
+ pty: Any,
+ checks: dict[str, bool],
+ notes: list[str],
+) -> None:
+ if scenario in {"rollback-step5-cleanup", "rollback-step5-cleanup-recovery"}:
+ _teardown_cleanup_scenario_resources(args=args, scenario=scenario, pty=pty, checks=checks, notes=notes)
+ return
+ if args.skip_final_teardown:
+ notes.append("final teardown skipped by --skip-final-teardown")
+ return
+
+ resources = _observed_create_stack_resources(pty)
+ if not resources:
+ checks["teardown: no observed ROS stacks leaked"] = True
+ return
+
+ deletion_failures: list[str] = []
+ deleted_stack_ids: list[str] = []
+ expected_scenario_stack_name = _scenario_stack_name(Path(getattr(pty, "run_dir", "")), scenario)
+ for resource in resources:
+ stack_id = _string_from_mapping(resource, "resource_id", "resourceId", "stack_id", "stackId")
+ if not stack_id:
+ continue
+ expected_stack_name = _string_from_mapping(resource, "resource_name", "resourceName", "stack_name", "stackName")
+ if expected_stack_name != expected_scenario_stack_name:
+ deletion_failures.append(
+ f"{stack_id} has unexpected test-owned stack name {expected_stack_name or ''}; "
+ f"expected {expected_scenario_stack_name}"
+ )
+ continue
+ state = _fresh_ros_stack_state(pty, stack_id)
+ if _ros_stack_deleted(state):
+ continue
+
+ actual_stack_name = str(state.get("stack_name") or "")
+ if not expected_stack_name:
+ deletion_failures.append(f"{stack_id} has no observed stack name in cleanup ledger")
+ continue
+ if actual_stack_name != expected_stack_name:
+ deletion_failures.append(
+ f"{stack_id} has unexpected stack name {actual_stack_name or ''}; "
+ f"expected observed name {expected_stack_name}"
+ )
+ continue
+
+ try:
+ _delete_ros_stack(
+ stack_id=stack_id,
+ region_id=str(state.get("region_id") or _region_for_stack(pty, stack_id)),
+ redaction_env=getattr(pty, "env", {}),
+ )
+ final_state = _wait_for_ros_stack_deleted(pty=pty, stack_id=stack_id, timeout=args.final_teardown_timeout)
+ if _ros_stack_deleted(final_state):
+ deleted_stack_ids.append(stack_id)
+ else:
+ deletion_failures.append(f"{stack_id} final status is {final_state.get('status') or ''}")
+ except Exception as exc:
+ deletion_failures.append(f"{stack_id}: {type(exc).__name__}: {exc}")
+
+ for failure in deletion_failures:
+ notes.append(f"final teardown failed: {_compact_text(failure, max_chars=1000)}")
+
+ checks["teardown: observed ROS stacks deleted"] = not deletion_failures
+ if deleted_stack_ids:
+ notes.append(f"final teardown deleted ROS stacks: {', '.join(deleted_stack_ids)}")
+
+
+def _apply_acceptance_checks(
+ scenario: str,
+ args: argparse.Namespace,
+ pty: Any,
+ checks: dict[str, bool],
+) -> None:
+ raw_transcript = str(getattr(pty, "transcript", ""))
+ transcript = _normalize_transcript(raw_transcript)
+ events = list(getattr(pty, "events", []))
+ _add_acceptance_check(checks, "PTY transcript captured", bool(transcript.strip()))
+ _add_acceptance_check(
+ checks,
+ "no terminal error in PTY transcript",
+ not _has_any_pattern(transcript, TERMINAL_ERROR_PATTERNS),
+ )
+
+ if scenario == "scenario1":
+ normal_answer = _suffix_after_sendline_text(raw_transcript, events, args.normal_followup_prompt)
+ _add_acceptance_check(
+ checks,
+ "candidate selection was shown",
+ _has_any_pattern(transcript, CANDIDATE_SELECTION_PATTERNS),
+ )
+ _add_acceptance_check(checks, "pipeline completed", _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS))
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ _add_acceptance_check(
+ checks,
+ "normal follow-up answered created VSwitch",
+ _has_vswitch_answer_evidence(normal_answer),
+ )
+ elif scenario == "image-initial":
+ _add_acceptance_check(checks, "initial image fixture was pasted", _has_image_fixture_event(events, "initial"))
+ _add_acceptance_check(
+ checks,
+ "candidate selection was shown",
+ _has_any_pattern(transcript, CANDIDATE_SELECTION_PATTERNS),
+ )
+ _add_acceptance_check(checks, "pipeline completed", _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS))
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ elif scenario == "image-ask-waiting-resume":
+ after_answer = _suffix_after_image_fixture(raw_transcript, events, "ask-first-answer")
+ _add_acceptance_check(
+ checks,
+ "ask user question was replayed after resume",
+ _count_pattern(transcript, ASK_USER_QUESTION_HEADING_PATTERNS) >= 2,
+ )
+ _add_acceptance_check(checks, "resume used --continue", bool(_resume_spawns(events)))
+ _add_acceptance_check(
+ checks,
+ "ask answer image fixture was pasted",
+ _has_image_fixture_event(events, "ask-first-answer"),
+ )
+ _add_acceptance_check(
+ checks,
+ "ask image answer advanced pipeline after resume",
+ _has_any_pattern(after_answer or transcript, CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ elif scenario == "image-selection-waiting-resume":
+ _add_acceptance_check(checks, "initial image fixture was pasted", _has_image_fixture_event(events, "initial"))
+ _add_acceptance_check(
+ checks,
+ "candidate selection was replayed after resume",
+ _count_pattern(transcript, CANDIDATE_SELECTION_PATTERNS) >= 2,
+ )
+ _add_acceptance_check(checks, "resume used --continue", bool(_resume_spawns(events)))
+ _add_acceptance_check(
+ checks,
+ "pipeline completed after resume",
+ _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ elif scenario == "image-normal-handoff":
+ normal_answer = _suffix_after_image_fixture(raw_transcript, events, "normal-followup")
+ _add_acceptance_check(
+ checks,
+ "candidate selection was shown",
+ _has_any_pattern(transcript, CANDIDATE_SELECTION_PATTERNS),
+ )
+ _add_acceptance_check(checks, "pipeline completed", _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS))
+ _add_acceptance_check(
+ checks,
+ "normal follow-up image fixture was pasted",
+ _has_image_fixture_event(events, "normal-followup"),
+ )
+ _add_acceptance_check(
+ checks,
+ "normal image follow-up answered created VSwitch",
+ _has_vswitch_answer_evidence(normal_answer),
+ )
+ elif scenario == "image-interrupt":
+ after_rollback = _suffix_after_image_fixture(raw_transcript, events, "rollback-interrupt")
+ _add_acceptance_check(
+ checks,
+ "rollback image fixture was pasted",
+ _has_image_fixture_event(events, "rollback-interrupt"),
+ )
+ _add_acceptance_check(
+ checks,
+ "rollback reached evaluate_candidates step",
+ _has_any_pattern(transcript, EVALUATE_CANDIDATES_HEADING_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "rollback image produced post-interrupt pipeline progress",
+ _has_any_pattern(after_rollback, POST_ROLLBACK_PROGRESS_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "post-rollback target is security group",
+ _has_security_group_target_evidence(after_rollback),
+ )
+ _add_acceptance_check(
+ checks,
+ "post-rollback target is not VSwitch",
+ not _has_positive_vswitch_target_evidence(after_rollback),
+ )
+ elif scenario == "ask-waiting":
+ after_answer = _normalize_transcript(
+ _last_event_suffix(raw_transcript, events, event_type="sendline", text=args.ask_answer)
+ )
+ _add_acceptance_check(checks, "ask user question was shown", "Ask user question" in transcript)
+ _add_acceptance_check(
+ checks,
+ "ask answer advanced pipeline",
+ _has_any_pattern(after_answer or transcript, CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ elif scenario == "selection-waiting-resume":
+ continue_spawns = _resume_spawns(events)
+ _add_acceptance_check(
+ checks,
+ "candidate selection was replayed after resume",
+ _count_pattern(transcript, CANDIDATE_SELECTION_PATTERNS) >= 2,
+ )
+ _add_acceptance_check(checks, "resume used --continue", bool(continue_spawns))
+ _add_acceptance_check(
+ checks,
+ "pipeline completed after resume",
+ _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ elif scenario == "rollback-step3":
+ after_rollback = _suffix_after_sendline_text(raw_transcript, events, args.rollback_prompt)
+ _add_acceptance_check(
+ checks,
+ "rollback reached evaluate_candidates step",
+ _has_any_pattern(transcript, EVALUATE_CANDIDATES_HEADING_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "rollback produced post-interrupt pipeline progress",
+ _has_any_pattern(after_rollback, POST_ROLLBACK_PROGRESS_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "post-rollback target is security group",
+ _has_security_group_target_evidence(after_rollback),
+ )
+ _add_acceptance_check(
+ checks,
+ "post-rollback target is not VSwitch",
+ not _has_positive_vswitch_target_evidence(after_rollback),
+ )
+ elif scenario == "rollback-step2":
+ after_rollback = _suffix_after_sendline_text(raw_transcript, events, args.rollback_prompt)
+ _add_acceptance_check(
+ checks,
+ "rollback reached architecture_planning step",
+ _has_any_pattern(transcript, ARCHITECTURE_PLANNING_HEADING_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "rollback produced post-interrupt pipeline progress",
+ _has_any_pattern(after_rollback, POST_ROLLBACK_PROGRESS_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "post-rollback target is security group",
+ _has_security_group_target_evidence(after_rollback),
+ )
+ _add_acceptance_check(
+ checks,
+ "post-rollback target is not VSwitch",
+ not _has_positive_vswitch_target_evidence(after_rollback),
+ )
+ elif scenario == "rollback-step4-selection":
+ after_rollback = _suffix_after_sendline_text(raw_transcript, events, args.rollback_prompt)
+ _add_acceptance_check(
+ checks,
+ "rollback reached candidate selection step",
+ _has_any_pattern(transcript, CANDIDATE_SELECTION_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "rollback produced post-interrupt pipeline progress",
+ _has_any_pattern(after_rollback, POST_ROLLBACK_PROGRESS_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "post-rollback target is security group",
+ _has_security_group_target_evidence(after_rollback),
+ )
+ _add_acceptance_check(
+ checks,
+ "post-rollback target is not VSwitch",
+ not _has_positive_vswitch_target_evidence(after_rollback),
+ )
+ elif scenario == "evaluate-resume":
+ after_continue = _normalize_transcript(
+ _last_event_suffix(
+ raw_transcript,
+ events,
+ event_type="sendline",
+ text=args.evaluate_resume_continue_prompt,
+ )
+ )
+ _add_acceptance_check(
+ checks,
+ "evaluate_candidates was shown before resume",
+ _has_any_pattern(transcript, EVALUATE_CANDIDATES_HEADING_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "evaluate_candidates was replayed after resume",
+ _count_pattern(transcript, EVALUATE_CANDIDATES_HEADING_PATTERNS) >= 2,
+ )
+ _add_acceptance_check(checks, "resume used --continue", bool(_resume_spawns(events)))
+ _add_acceptance_check(
+ checks,
+ "resume continue input was sent",
+ _has_sendline_event(events, args.evaluate_resume_continue_prompt),
+ )
+ _add_acceptance_check(
+ checks,
+ "pipeline advanced after resume continue",
+ _has_any_pattern(after_continue or transcript, CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ elif scenario == "ask-waiting-resume":
+ after_answer = _normalize_transcript(
+ _last_event_suffix(raw_transcript, events, event_type="sendline", text=args.ask_answer)
+ )
+ _add_acceptance_check(
+ checks,
+ "ask user question was replayed after resume",
+ _count_pattern(transcript, ASK_USER_QUESTION_HEADING_PATTERNS) >= 2,
+ )
+ _add_acceptance_check(checks, "resume used --continue", bool(_resume_spawns(events)))
+ _add_acceptance_check(
+ checks,
+ "ask answer advanced pipeline after resume",
+ _has_any_pattern(after_answer or transcript, CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS),
+ )
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ elif scenario == "selection-invalid-then-valid":
+ _add_acceptance_check(
+ checks,
+ "invalid selection input was sent",
+ _event_index(events, "select-invalid-candidate") is not None,
+ )
+ _add_acceptance_check(
+ checks,
+ "valid selection input was sent after invalid input",
+ _event_before(events, "select-invalid-candidate", "select-default-candidate"),
+ )
+ _add_acceptance_check(checks, "pipeline completed", _has_any_pattern(transcript, PIPELINE_COMPLETED_PATTERNS))
+ _add_acceptance_check(
+ checks,
+ "VSwitch evidence found in PTY transcript",
+ _has_vswitch_business_evidence(transcript),
+ )
+ elif scenario in {"rollback-step5-cleanup", "rollback-step5-cleanup-recovery"}:
+ _add_acceptance_check(
+ checks,
+ "deploying step was reached",
+ _has_any_pattern(transcript, DEPLOYING_STEP_PATTERNS + FIRST_STACK_CREATED_PATTERNS),
+ )
+ _add_acceptance_check(checks, "cleanup started", _has_any_pattern(transcript, CLEANUP_STARTED_PATTERNS))
+ _apply_cleanup_acceptance_checks(
+ scenario=scenario,
+ transcript=transcript,
+ events=events,
+ pty=pty,
+ checks=checks,
+ )
+ _apply_stack_creating_acceptance_checks(scenario, pty, checks)
+
+
+def _select_default_candidate(pty: ReplPty, args: argparse.Namespace) -> None:
+ if args.selection_prompt:
+ pty.send(f"{args.selection_prompt}\r", label="select-default-candidate")
+ else:
+ pty.send("\r", label="select-default-candidate")
+
+
+def _expect_initial_prompt(pty: ReplPty, args: argparse.Namespace) -> None:
+ pty.expect_any(REPL_PROMPT_PATTERNS, description="initial prompt", timeout=args.timeout)
+ pty.expect_any(REPL_INPUT_READY_PATTERNS, description="prompt input ready", timeout=args.timeout)
+
+
+def _expect_candidate_selection(pty: ReplPty, args: argparse.Namespace, *, description: str) -> None:
+ pty.expect_any(CANDIDATE_SELECTION_PATTERNS, description=description, timeout=args.stream_timeout)
+ pty.expect_optional(
+ CANDIDATE_SELECTION_READY_PATTERNS,
+ description="candidate selection controls ready",
+ timeout=args.candidate_selection_ready_timeout,
+ )
+
+
+def _expect_raw_input_ready(pty: ReplPty, args: argparse.Namespace, *, description: str) -> None:
+ pty.expect_any(REPL_INPUT_READY_PATTERNS, description=description, timeout=args.timeout)
+
+
+def _expect_parallel_interrupt_ready(pty: ReplPty, args: argparse.Namespace) -> None:
+ _expect_raw_input_ready(pty, args, description="parallel interrupt input ready")
+
+
+def _wait_for_cleanup_completed_and_ready(pty: ReplPty, args: argparse.Namespace, first_stack_id: str) -> None:
+ _wait_for_cleanup_resource_status(pty, first_stack_id, {"completed"}, timeout=args.stream_timeout)
+ pty.expect_optional(
+ CLEANUP_COMPLETED_PATTERNS,
+ description="cleanup completed",
+ timeout=min(args.timeout, 5.0),
+ )
+ _expect_raw_input_ready(pty, args, description="post-cleanup prompt input ready")
+
+
+def _finish_vswitch_pipeline_after_possible_selection(
+ pty: ReplPty,
+ args: argparse.Namespace,
+ checks: dict[str, bool],
+ matched_pattern: str,
+ *,
+ selection_check: str,
+ completion_check: str,
+ completion_description: str,
+) -> None:
+ if matched_pattern in CANDIDATE_SELECTION_PATTERNS:
+ pty.expect_optional(
+ CANDIDATE_SELECTION_READY_PATTERNS,
+ description="candidate selection controls ready after ask",
+ timeout=args.candidate_selection_ready_timeout,
+ )
+ _select_default_candidate(pty, args)
+ checks[selection_check] = True
+ pty.expect_any(PIPELINE_COMPLETED_PATTERNS, description=completion_description, timeout=args.stream_timeout)
+ checks[completion_check] = True
+
+
+def _expect_post_rollback_security_group_target(
+ pty: ReplPty,
+ args: argparse.Namespace,
+ checks: dict[str, bool],
+) -> None:
+ pty.expect_any(
+ SECURITY_GROUP_MENTION_PATTERNS,
+ description="post-rollback security group target visible",
+ timeout=min(args.stream_timeout, 300.0),
+ )
+ checks["post-rollback security group target visible"] = True
+
+
+def run_scenario1(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario))
+ pty.expect_any(PIPELINE_STARTED_PATTERNS, description="pipeline started", timeout=args.stream_timeout)
+ checks["pipeline started"] = True
+ _expect_candidate_selection(pty, args, description="candidate selection visible")
+ checks["candidate selection became visible"] = True
+ _select_default_candidate(pty, args)
+ checks["candidate selection input sent"] = True
+ pty.expect_any(
+ PIPELINE_FULLY_COMPLETED_PATTERNS,
+ description="pipeline fully completed",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline completed"] = True
+ _expect_raw_input_ready(pty, args, description="normal prompt input ready")
+ checks["normal prompt input ready"] = True
+ pty.sendline(args.normal_followup_prompt)
+ pty.expect_any(
+ VSWITCH_MENTION_PATTERNS,
+ description="normal follow-up answered created VSwitch",
+ timeout=min(args.stream_timeout, 120.0),
+ )
+ checks["normal follow-up answered created VSwitch"] = True
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_ask_waiting(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(args.ask_prompt)
+ pty.expect_any(ASK_PATTERNS, description="ask question visible", timeout=args.stream_timeout)
+ checks["ask question became visible"] = True
+ pty.sendline(_stack_creating_prompt(args.ask_answer, pty.run_dir, scenario))
+ checks["ask answer sent"] = True
+ matched = pty.expect_any(
+ CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS,
+ description="pipeline continued after ask",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline continued beyond ask"] = True
+ _finish_vswitch_pipeline_after_possible_selection(
+ pty,
+ args,
+ checks,
+ matched,
+ selection_check="candidate selection input sent after ask",
+ completion_check="pipeline completed after ask",
+ completion_description="pipeline completed after ask",
+ )
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_image_initial(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ _submit_image_fixture(pty, "initial", caption=_stack_name_constraint(pty.run_dir, scenario))
+ checks["initial image fixture pasted"] = True
+ pty.expect_any(PIPELINE_STARTED_PATTERNS, description="pipeline started", timeout=args.stream_timeout)
+ checks["pipeline started"] = True
+ _expect_candidate_selection(pty, args, description="candidate selection visible")
+ checks["candidate selection became visible"] = True
+ _select_default_candidate(pty, args)
+ checks["candidate selection input sent"] = True
+ pty.expect_any(
+ PIPELINE_COMPLETED_PATTERNS,
+ description="pipeline completed after image initial",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline completed after image initial"] = True
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_image_ask_waiting_resume(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(args.ask_prompt)
+ pty.expect_any(ASK_PATTERNS, description="ask question visible before kill", timeout=args.stream_timeout)
+ checks["ask question became visible before kill"] = True
+ pty.terminate(force=True)
+ checks["first process killed"] = True
+ pty.spawn(extra_args=["--continue"])
+ pty.expect_any(ASK_PATTERNS, description="ask question replayed", timeout=args.stream_timeout)
+ checks["ask question replayed"] = True
+ _expect_raw_input_ready(pty, args, description="ask image answer input ready after resume")
+ checks["ask image answer input ready after resume"] = True
+ _submit_image_fixture(pty, "ask-first-answer", caption=_stack_name_constraint(pty.run_dir, scenario))
+ checks["ask first answer image fixture pasted after resume"] = True
+ if pty.expect_optional(
+ ASK_PATTERNS,
+ description="second ask question after image answer",
+ timeout=min(args.timeout, 30.0),
+ ):
+ _expect_raw_input_ready(pty, args, description="second ask image answer input ready")
+ _submit_image_fixture(pty, "ask-second-answer", caption=_stack_name_constraint(pty.run_dir, scenario))
+ checks["ask second answer image fixture pasted"] = True
+ matched = pty.expect_any(
+ CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS,
+ description="pipeline continued after ask image resume",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline continued beyond ask image after resume"] = True
+ _finish_vswitch_pipeline_after_possible_selection(
+ pty,
+ args,
+ checks,
+ matched,
+ selection_check="candidate selection input sent after ask image resume",
+ completion_check="pipeline completed after ask image resume",
+ completion_description="pipeline completed after ask image resume",
+ )
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_image_selection_waiting_resume(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ _submit_image_fixture(pty, "initial", caption=_stack_name_constraint(pty.run_dir, scenario))
+ checks["initial image fixture pasted"] = True
+ _expect_candidate_selection(pty, args, description="candidate selection visible before image resume kill")
+ checks["candidate selection became visible before kill"] = True
+ pty.terminate(force=True)
+ checks["first process killed"] = True
+ pty.spawn(extra_args=["--continue"])
+ _expect_candidate_selection(pty, args, description="candidate selection replayed after image resume")
+ checks["candidate selection replayed after resume"] = True
+ _select_default_candidate(pty, args)
+ checks["candidate selection input sent after resume"] = True
+ pty.expect_any(
+ PIPELINE_COMPLETED_PATTERNS,
+ description="pipeline completed after image selection resume",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline completed after image selection resume"] = True
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_image_normal_handoff(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario))
+ pty.expect_any(PIPELINE_STARTED_PATTERNS, description="pipeline started", timeout=args.stream_timeout)
+ checks["pipeline started"] = True
+ _expect_candidate_selection(pty, args, description="candidate selection visible")
+ checks["candidate selection became visible"] = True
+ _select_default_candidate(pty, args)
+ checks["candidate selection input sent"] = True
+ pty.expect_any(
+ PIPELINE_FULLY_COMPLETED_PATTERNS,
+ description="pipeline fully completed",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline completed"] = True
+ _expect_raw_input_ready(pty, args, description="normal prompt input ready")
+ checks["normal prompt input ready"] = True
+ _submit_image_fixture(pty, "normal-followup")
+ checks["normal follow-up image fixture pasted"] = True
+ pty.expect_any(
+ VSWITCH_MENTION_PATTERNS,
+ description="normal image follow-up answered created VSwitch",
+ timeout=min(args.stream_timeout, 120.0),
+ )
+ checks["normal image follow-up answered created VSwitch"] = True
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_image_interrupt(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(args.initial_prompt)
+ pty.expect_any(
+ CANDIDATE_EVALUATION_PATTERNS,
+ description="candidate evaluation visible",
+ timeout=args.stream_timeout,
+ )
+ checks["candidate evaluation reached"] = True
+ _expect_parallel_interrupt_ready(pty, args)
+ checks["parallel interrupt input ready"] = True
+ pty.send("\x1b", label="send-esc")
+ checks["esc sent"] = True
+ pty.expect_any(
+ REPL_INPUT_READY_PATTERNS, description="parallel interrupt text input ready", timeout=args.timeout
+ )
+ checks["parallel interrupt text input ready"] = True
+ _submit_image_fixture(pty, "rollback-interrupt")
+ checks["rollback interrupt image fixture pasted"] = True
+ pty.expect_any(
+ POST_ROLLBACK_PROGRESS_PATTERNS,
+ description="post-rollback pipeline progress visible",
+ timeout=args.stream_timeout,
+ )
+ checks["post-rollback pipeline progress visible"] = True
+ _expect_post_rollback_security_group_target(pty, args, checks)
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_selection_waiting_resume(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario))
+ _expect_candidate_selection(pty, args, description="candidate selection visible")
+ checks["candidate selection became visible before kill"] = True
+ pty.terminate(force=True)
+ checks["first process killed"] = True
+ pty.spawn(extra_args=["--continue"])
+ _expect_candidate_selection(pty, args, description="candidate selection replayed")
+ checks["candidate selection replayed"] = True
+ _select_default_candidate(pty, args)
+ checks["candidate selection input sent after resume"] = True
+ pty.expect_any(
+ PIPELINE_COMPLETED_PATTERNS, description="pipeline completed after resume", timeout=args.stream_timeout
+ )
+ checks["pipeline completed after resume"] = True
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_ask_waiting_resume(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(args.ask_prompt)
+ pty.expect_any(ASK_PATTERNS, description="ask question visible before kill", timeout=args.stream_timeout)
+ checks["ask question became visible before kill"] = True
+ pty.terminate(force=True)
+ checks["first process killed"] = True
+ pty.spawn(extra_args=["--continue"])
+ pty.expect_any(ASK_PATTERNS, description="ask question replayed", timeout=args.stream_timeout)
+ checks["ask question replayed"] = True
+ _expect_raw_input_ready(pty, args, description="ask answer input ready after resume")
+ checks["ask answer input ready after resume"] = True
+ pty.sendline(_stack_creating_prompt(args.ask_answer, pty.run_dir, scenario))
+ checks["ask answer sent after resume"] = True
+ matched = pty.expect_any(
+ CANDIDATE_SELECTION_PATTERNS + PIPELINE_COMPLETED_PATTERNS,
+ description="pipeline continued after ask resume",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline continued beyond ask after resume"] = True
+ _finish_vswitch_pipeline_after_possible_selection(
+ pty,
+ args,
+ checks,
+ matched,
+ selection_check="candidate selection input sent after ask resume",
+ completion_check="pipeline completed after ask resume",
+ completion_description="pipeline completed after ask resume",
+ )
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_evaluate_resume(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario))
+ pty.expect_any(
+ CANDIDATE_EVALUATION_PATTERNS, description="candidate evaluation visible", timeout=args.stream_timeout
+ )
+ checks["candidate evaluation reached before kill"] = True
+ _expect_parallel_interrupt_ready(pty, args)
+ checks["parallel interrupt input ready before kill"] = True
+ pty.terminate(force=True)
+ checks["first process killed"] = True
+ pty.spawn(extra_args=["--continue"])
+ pty.expect_any(
+ EVALUATE_CANDIDATES_HEADING_PATTERNS,
+ description="candidate evaluation replayed after resume",
+ timeout=args.stream_timeout,
+ )
+ checks["candidate evaluation replayed after resume"] = True
+ _expect_raw_input_ready(pty, args, description="evaluate resume prompt input ready")
+ checks["evaluate resume prompt input ready"] = True
+ pty.sendline(args.evaluate_resume_continue_prompt)
+ checks["resume continue input sent"] = True
+ _expect_candidate_selection(pty, args, description="candidate selection visible after resume continue")
+ checks["candidate selection became visible after resume continue"] = True
+ _select_default_candidate(pty, args)
+ checks["candidate selection input sent after resume"] = True
+ pty.expect_any(
+ PIPELINE_COMPLETED_PATTERNS,
+ description="pipeline completed after evaluate resume",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline completed after evaluate resume"] = True
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_selection_invalid_then_valid(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(_stack_creating_prompt(args.initial_prompt, pty.run_dir, scenario))
+ _expect_candidate_selection(pty, args, description="candidate selection visible")
+ checks["candidate selection became visible"] = True
+ pty.send(args.invalid_selection_prompt, label="select-invalid-candidate")
+ checks["invalid selection input sent"] = True
+ _select_default_candidate(pty, args)
+ checks["valid selection input sent after invalid input"] = True
+ pty.expect_any(PIPELINE_COMPLETED_PATTERNS, description="pipeline completed", timeout=args.stream_timeout)
+ checks["pipeline completed"] = True
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_rollback_step2(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(args.initial_prompt)
+ pty.expect_any(
+ ARCHITECTURE_PLANNING_PATTERNS,
+ description="architecture planning visible",
+ timeout=args.stream_timeout,
+ )
+ checks["architecture planning reached"] = True
+ pty.send("\x1b", label="send-esc")
+ checks["esc sent"] = True
+ pty.expect_any(INTERRUPT_INPUT_PATTERNS, description="interrupt input visible", timeout=args.timeout)
+ checks["interrupt input visible"] = True
+ _expect_raw_input_ready(pty, args, description="interrupt prompt input ready")
+ checks["interrupt prompt input ready"] = True
+ pty.sendline(args.rollback_prompt)
+ checks["rollback prompt sent"] = True
+ pty.expect_any(
+ POST_ROLLBACK_PROGRESS_PATTERNS,
+ description="post-rollback pipeline progress visible",
+ timeout=args.stream_timeout,
+ )
+ checks["post-rollback pipeline progress visible"] = True
+ _expect_post_rollback_security_group_target(pty, args, checks)
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_rollback_step3(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(args.initial_prompt)
+ pty.expect_any(
+ CANDIDATE_EVALUATION_PATTERNS,
+ description="candidate evaluation visible",
+ timeout=args.stream_timeout,
+ )
+ checks["candidate evaluation reached"] = True
+ _expect_parallel_interrupt_ready(pty, args)
+ checks["parallel interrupt input ready"] = True
+ pty.send("\x1b", label="send-esc")
+ checks["esc sent"] = True
+ pty.expect_any(
+ REPL_INPUT_READY_PATTERNS, description="parallel interrupt text input ready", timeout=args.timeout
+ )
+ checks["parallel interrupt text input ready"] = True
+ pty.sendline(args.rollback_prompt)
+ checks["rollback prompt sent"] = True
+ pty.expect_any(
+ POST_ROLLBACK_PROGRESS_PATTERNS,
+ description="post-rollback pipeline progress visible",
+ timeout=args.stream_timeout,
+ )
+ checks["post-rollback pipeline progress visible"] = True
+ _expect_post_rollback_security_group_target(pty, args, checks)
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_rollback_step4_selection(args: argparse.Namespace, scenario: str) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ pty.sendline(args.initial_prompt)
+ _expect_candidate_selection(pty, args, description="candidate selection visible")
+ checks["candidate selection reached"] = True
+ _expect_raw_input_ready(pty, args, description="candidate selection input ready")
+ checks["candidate selection input ready"] = True
+ pty.send("\x1b", label="send-esc")
+ checks["esc sent"] = True
+ _expect_raw_input_ready(pty, args, description="candidate selection interrupt text input ready")
+ checks["candidate selection interrupt text input ready"] = True
+ pty.sendline(args.rollback_prompt)
+ checks["rollback prompt sent"] = True
+ pty.expect_any(
+ POST_ROLLBACK_PROGRESS_PATTERNS,
+ description="post-rollback pipeline progress visible",
+ timeout=args.stream_timeout,
+ )
+ checks["post-rollback pipeline progress visible"] = True
+ _expect_post_rollback_security_group_target(pty, args, checks)
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+def run_rollback_step5_cleanup(args: argparse.Namespace, scenario: str) -> int:
+ return _run_rollback_step5_cleanup(args, scenario, kill_during_cleanup=False)
+
+
+def run_rollback_step5_cleanup_recovery(args: argparse.Namespace, scenario: str) -> int:
+ return _run_rollback_step5_cleanup(args, scenario, kill_during_cleanup=True)
+
+
+def _run_rollback_step5_cleanup(
+ args: argparse.Namespace,
+ scenario: str,
+ *,
+ kill_during_cleanup: bool,
+) -> int:
+ def callback(pty: ReplPty, checks: dict[str, bool]) -> None:
+ _expect_initial_prompt(pty, args)
+ _ensure_cleanup_network_target(args, pty.run_dir)
+ checks["cleanup network target prepared"] = True
+ pty.sendline(_cleanup_pipeline_prompt(args, pty.run_dir))
+ _expect_candidate_selection(pty, args, description="initial candidate selection visible")
+ checks["initial reached step4 selection"] = True
+
+ _select_default_candidate(pty, args)
+ checks["initial candidate selected"] = True
+ pty.expect_any(
+ CREATE_STACK_STARTED_PATTERNS,
+ description="first stack create started",
+ timeout=args.stream_timeout,
+ )
+ first_stack_id = _wait_for_latest_observed_stack_id(pty, exclude=set(), timeout=args.stream_timeout)
+ pty.cleanup_first_stack_id = first_stack_id
+ checks["first rollback stack observed before rollback"] = bool(first_stack_id)
+
+ pty.send("\x1b", label="send-esc")
+ checks["esc sent during deploying"] = True
+ _expect_raw_input_ready(pty, args, description="deploying interrupt input ready")
+ checks["deploying interrupt input ready"] = True
+ pty.sendline(_cleanup_rollback_prompt(args, pty.run_dir))
+ checks["rollback prompt sent"] = True
+ _expect_candidate_selection(pty, args, description="post-rollback candidate selection visible")
+ checks["post-rollback candidate selection visible"] = True
+
+ cleanup_stack_ids = _wait_for_cleanup_target_stack_ids(pty, exclude=set(), timeout=args.timeout)
+ checks["rollback cleanup ledger includes first stack"] = first_stack_id in cleanup_stack_ids
+ checks["rollback cleanup target stacks observed"] = bool(cleanup_stack_ids)
+
+ _select_default_candidate(pty, args)
+ checks["post-rollback candidate selected"] = True
+ pty.expect_any(
+ PIPELINE_FULLY_COMPLETED_PATTERNS,
+ description="pipeline completed after second deployment",
+ timeout=args.stream_timeout,
+ )
+ checks["pipeline completed after second deployment"] = True
+
+ second_stack_id = _latest_observed_stack_id(pty, exclude=set(cleanup_stack_ids) | {first_stack_id})
+ pty.cleanup_second_stack_id = second_stack_id or ""
+ checks["second stack created after rollback"] = bool(second_stack_id)
+ checks["second stack differs from first rollback stack"] = (
+ bool(second_stack_id) and second_stack_id != first_stack_id
+ )
+
+ cleanup_stack_ids = _cleanup_target_stack_ids(
+ pty,
+ exclude={stack_id for stack_id in [second_stack_id] if stack_id},
+ )
+ checks["rollback cleanup ledger includes first stack"] = first_stack_id in cleanup_stack_ids
+ checks["rollback cleanup target stacks observed"] = bool(cleanup_stack_ids)
+ checks["cleanup snapshot does not target second stack"] = (
+ bool(second_stack_id) and _cleanup_resource_for_stack(pty, second_stack_id) is None
+ )
+
+ pty.sendline(args.normal_followup_prompt)
+ if kill_during_cleanup:
+ pty.expect_any(
+ CLEANUP_STARTED_PATTERNS,
+ description="cleanup started before kill",
+ timeout=args.stream_timeout,
+ )
+ checks["cleanup started before kill"] = True
+ pty.terminate(force=True)
+ checks["cleanup process killed"] = True
+ pty.spawn(extra_args=["--continue"])
+ pty.expect_any(
+ CLEANUP_RESUME_SUMMARY_PATTERNS,
+ description="cleanup resume summary",
+ timeout=args.stream_timeout,
+ )
+ if _cleanup_resource_completed(_cleanup_resource_for_stack(pty, first_stack_id)):
+ checks["cleanup already completed after restart"] = True
+ else:
+ _expect_raw_input_ready(pty, args, description="cleanup resume prompt input ready")
+ pty.sendline(args.cleanup_continue_prompt)
+ checks["cleanup continue prompt sent after restart"] = True
+ else:
+ pty.expect_any(CLEANUP_STARTED_PATTERNS, description="cleanup started", timeout=args.stream_timeout)
+ checks["cleanup started"] = True
+
+ _wait_for_cleanup_completed_and_ready(pty, args, first_stack_id)
+ checks["first rollback stack cleanup completed in ledger"] = _cleanup_resource_completed(
+ _cleanup_resource_for_stack(pty, first_stack_id)
+ )
+ checks["rollback cleanup stacks completed in ledger"] = bool(cleanup_stack_ids) and all(
+ _cleanup_resource_completed(_cleanup_resource_for_stack(pty, stack_id)) for stack_id in cleanup_stack_ids
+ )
+ pty.sendline("/exit")
+
+ return _run_with_pty(args, scenario, callback)
+
+
+_SCENARIOS: dict[str, Callable[[argparse.Namespace, str], int]] = {
+ "scenario1": run_scenario1,
+ "ask-waiting": run_ask_waiting,
+ "ask-waiting-resume": run_ask_waiting_resume,
+ "image-initial": run_image_initial,
+ "image-ask-waiting-resume": run_image_ask_waiting_resume,
+ "image-selection-waiting-resume": run_image_selection_waiting_resume,
+ "image-normal-handoff": run_image_normal_handoff,
+ "image-interrupt": run_image_interrupt,
+ "evaluate-resume": run_evaluate_resume,
+ "selection-invalid-then-valid": run_selection_invalid_then_valid,
+ "selection-waiting-resume": run_selection_waiting_resume,
+ "rollback-step2": run_rollback_step2,
+ "rollback-step3": run_rollback_step3,
+ "rollback-step4-selection": run_rollback_step4_selection,
+ "rollback-step5-cleanup": run_rollback_step5_cleanup,
+ "rollback-step5-cleanup-recovery": run_rollback_step5_cleanup_recovery,
+}
+_REAL_CLOUD_SCENARIOS = frozenset(_SCENARIOS)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/iac_code/a2a/app.py b/src/iac_code/a2a/app.py
index 572f3b49..096b5471 100644
--- a/src/iac_code/a2a/app.py
+++ b/src/iac_code/a2a/app.py
@@ -6,6 +6,7 @@
import hashlib
import hmac
import json
+import logging
import os
from contextlib import asynccontextmanager, suppress
from email.utils import formatdate
@@ -25,8 +26,13 @@
from starlette.routing import BaseRoute, Route
from iac_code.a2a.agent_card import agent_card_to_client_dict
+from iac_code.a2a.jsonrpc_passthrough import (
+ install_jsonrpc_error_data_passthrough,
+ install_v03_jsonrpc_error_data_passthrough,
+)
from iac_code.i18n import _
+logger = logging.getLogger(__name__)
_V03_JSONRPC_METHODS = frozenset(
{
"message/send",
@@ -273,7 +279,9 @@ async def get_pipeline_state(request: Request) -> JSONResponse:
Route(AGENT_CARD_WELL_KNOWN_PATH, get_agent_card, methods=["GET"]),
Route("/iac-code/pipeline/state", get_pipeline_state, methods=["GET"]),
]
+ install_jsonrpc_error_data_passthrough()
jsonrpc_endpoint = create_jsonrpc_routes(components.handler, rpc_url="/", enable_v0_3_compat=True)[0].endpoint
+ install_v03_jsonrpc_error_data_passthrough(jsonrpc_endpoint)
async def handle_jsonrpc(request: Request) -> Response:
await normalize_v03_jsonrpc_version(request)
diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py
index 7a4821c3..f50710c9 100644
--- a/src/iac_code/a2a/executor.py
+++ b/src/iac_code/a2a/executor.py
@@ -2,10 +2,11 @@
import asyncio
import contextlib
+import json
import logging
import os
import uuid
-from collections.abc import Awaitable, Callable, Mapping
+from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from pathlib import Path
from typing import Any, TypeAlias
@@ -13,15 +14,25 @@
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.types import Message, Role, Task, TaskState, TaskStatus, TaskStatusUpdateEvent
+from a2a.utils.errors import InvalidParamsError
from google.protobuf.json_format import MessageToDict
from iac_code.a2a.events import make_text_part, publish_stream_event
from iac_code.a2a.exposure import normalize_a2a_exposure_types
from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics
-from iac_code.a2a.parts import allowed_cwd_roots, is_relative_to, parts_to_prompt, resolve_workspace_path
+from iac_code.a2a.parts import (
+ allowed_cwd_roots,
+ is_relative_to,
+ parts_to_pipeline_input,
+ parts_to_prompt,
+ resolve_workspace_path,
+)
+from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator
from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, recoverable_task_id_from_sidecar
+from iac_code.a2a.pipeline_journal import A2APipelineJournal
from iac_code.a2a.pipeline_paths import existing_a2a_pipeline_dir_for_session
-from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore
+from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events
+from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher
from iac_code.a2a.task_store import A2ATaskStore
from iac_code.a2a.types import (
TASK_STATE_CANCELED,
@@ -30,17 +41,38 @@
TASK_STATE_WORKING,
)
from iac_code.agent.message import Message as AgentMessage
+from iac_code.config import get_active_provider_key, get_provider_config, load_credentials
from iac_code.i18n import _
from iac_code.pipeline.config import RunMode, get_run_mode
+from iac_code.pipeline.constants import (
+ PIPELINE_EVENT_CLEANUP_COMPLETED,
+ PIPELINE_EVENT_CLEANUP_FAILED,
+ PIPELINE_EVENT_CLEANUP_PROGRESS,
+ PIPELINE_EVENT_CLEANUP_STARTED,
+)
+from iac_code.pipeline.engine.cleanup import (
+ CLEANUP_PROMPT_METADATA_TYPE,
+ CleanupLedger,
+ CleanupObserver,
+ cleanup_prompt_ledger_path,
+ create_cleanup_prompt_message,
+ is_active_cleanup_prompt_message,
+ mark_cleanup_prompt_message_completed,
+)
+from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input
from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime
+from iac_code.services.capabilities.multimodal import is_model_multimodal
from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential, use_aliyun_credential
from iac_code.services.session_storage import SessionStorage
from iac_code.services.telemetry import use_session_id, use_user_id
+from iac_code.types.stream_events import TextDeltaEvent
+from iac_code.utils.file_security import atomic_write_text, ensure_private_dir, ensure_private_file
from iac_code.utils.public_errors import public_exception_summary, sanitize_public_text
logger = logging.getLogger(__name__)
_CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1
_ERROR_TEXT_MAX_CHARS = 1000
+_DEFERRED_CLEANUP_PROMPTS_FILENAME = "cleanup-deferred-prompts.json"
def _format_exception(exc: BaseException) -> str:
@@ -58,6 +90,671 @@ def _is_relative_to(path: Path, root: Path) -> bool:
return is_relative_to(path, root)
+def _cleanup_prompt_from_handoff(handoff: dict[str, Any]) -> str | None:
+ data = handoff.get("data")
+ if not isinstance(data, dict):
+ return None
+ cleanup = data.get("cleanup")
+ if not isinstance(cleanup, dict):
+ return None
+ prompt = cleanup.get("prompt")
+ return prompt if isinstance(prompt, str) and prompt else None
+
+
+def _cleanup_ledger_path_from_handoff(handoff: dict[str, Any]) -> str | None:
+ data = handoff.get("data")
+ if not isinstance(data, dict):
+ return None
+ cleanup = data.get("cleanup")
+ if not isinstance(cleanup, dict):
+ return None
+ path = cleanup.get("ledgerPath") or cleanup.get("ledger_path")
+ return path if isinstance(path, str) and path else None
+
+
+def _cleanup_payload_from_private_ledger_or_unavailable(
+ *,
+ ledger_path: Path,
+) -> dict[str, Any]:
+ ledger = CleanupLedger(ledger_path)
+ try:
+ ledger_exists = ledger_path.exists()
+ except OSError:
+ ledger_exists = False
+ if not ledger_exists or ledger.load_failed():
+ return {
+ "status": "unavailable",
+ "statusMessage": _("Cleanup state unavailable. Inspect the session file and cloud resources manually."),
+ }
+ prompt = ledger.build_pending_prompt()
+ if prompt is None:
+ return {"status": "completed", "resourceCount": 0}
+ return {
+ "status": "pending",
+ "resourceCount": len(prompt.resources),
+ "statusMessage": prompt.status_message,
+ "prompt": prompt.prompt,
+ "ledgerPath": str(ledger_path),
+ }
+
+
+def _session_has_user_message(
+ messages: list[AgentMessage],
+ *,
+ content: str,
+ metadata_type: str | None = None,
+) -> bool:
+ for message in messages:
+ if getattr(message, "role", None) != "user" or getattr(message, "content", None) != content:
+ continue
+ if metadata_type is None:
+ return True
+ metadata = getattr(message, "metadata", None)
+ if isinstance(metadata, dict) and metadata.get("type") == metadata_type:
+ return True
+ return False
+
+
+def _messages_have_cleanup_prompt(messages: list[Any]) -> bool:
+ return any(_message_is_cleanup_prompt(message) for message in messages)
+
+
+def _messages_have_active_cleanup_prompt(messages: list[Any]) -> bool:
+ return any(is_active_cleanup_prompt_message(message) for message in messages)
+
+
+def _session_has_active_cleanup_prompt_content(messages: list[AgentMessage], *, content: str) -> bool:
+ for message in messages:
+ if getattr(message, "role", None) != "user" or getattr(message, "content", None) != content:
+ continue
+ if is_active_cleanup_prompt_message(message):
+ return True
+ return False
+
+
+def _message_is_cleanup_prompt(message: Any) -> bool:
+ metadata = getattr(message, "metadata", None)
+ return isinstance(metadata, dict) and metadata.get("type") == CLEANUP_PROMPT_METADATA_TYPE
+
+
+def _cleanup_ledger_for_a2a_normal_chat(*, cwd: str, session_id: str) -> CleanupLedger | None:
+ try:
+ messages = SessionStorage().load(cwd, session_id)
+ except Exception:
+ logger.warning("Failed to inspect A2A session cleanup prompt", exc_info=True)
+ messages = []
+ has_active_cleanup_prompt = False
+ for message in messages:
+ if not is_active_cleanup_prompt_message(message):
+ continue
+ has_active_cleanup_prompt = True
+ ledger_path = cleanup_prompt_ledger_path(message)
+ if ledger_path:
+ return CleanupLedger(ledger_path)
+ try:
+ path = SessionStorage().session_dir(cwd, session_id) / "pipeline" / "cleanup.yaml"
+ except Exception:
+ logger.warning("Failed to locate A2A pipeline cleanup ledger", exc_info=True)
+ return None
+ if not path.exists():
+ return None
+ ledger = CleanupLedger(path)
+ if has_active_cleanup_prompt:
+ return ledger
+ if ledger.load_failed():
+ return None
+ return ledger if ledger.pending_resources() else None
+
+
+def _default_cleanup_ledger_path(*, cwd: str, session_id: str) -> Path:
+ return SessionStorage().session_dir(cwd, session_id) / "pipeline" / "cleanup.yaml"
+
+
+def _ensure_cleanup_prompt_in_session(*, cwd: str, session_id: str, ledger: CleanupLedger, runtime: Any) -> None:
+ cleanup_prompt = ledger.build_pending_prompt()
+ if cleanup_prompt is None:
+ return
+ message = create_cleanup_prompt_message(
+ cleanup_prompt.prompt,
+ cleanup_ledger_path=ledger.path,
+ cleanup_status="pending",
+ )
+ session_storage = SessionStorage()
+ messages = session_storage.load(cwd, session_id)
+ if _session_has_active_cleanup_prompt_content(
+ messages,
+ content=cleanup_prompt.prompt,
+ ):
+ _ensure_cleanup_prompt_in_runtime(runtime=runtime, message=message)
+ return
+ session_storage.append(cwd, session_id, message)
+ ledger.record_prompt_queued(cleanup_prompt, ui_surface="a2a")
+ _ensure_cleanup_prompt_in_runtime(runtime=runtime, message=message)
+
+
+def _ensure_cleanup_prompt_in_runtime(*, runtime: Any, message: AgentMessage) -> None:
+ context_manager = getattr(getattr(runtime, "agent_loop", None), "context_manager", None)
+ remover = getattr(context_manager, "remove_cleanup_prompt_messages", None)
+ add_raw_message = getattr(context_manager, "add_raw_message", None)
+ if not callable(add_raw_message):
+ return
+ if callable(remover):
+ try:
+ remover()
+ except Exception:
+ logger.warning("Failed to replace A2A cleanup prompt in runtime context", exc_info=True)
+ try:
+ add_raw_message(message.to_dict())
+ except Exception:
+ logger.warning("Failed to inject A2A cleanup prompt into runtime context", exc_info=True)
+
+
+def _runtime_has_cleanup_prompt(runtime: Any) -> bool:
+ context_manager = getattr(getattr(runtime, "agent_loop", None), "context_manager", None)
+ get_messages = getattr(context_manager, "get_messages", None)
+ if not callable(get_messages):
+ return False
+ try:
+ messages = get_messages()
+ except Exception:
+ return False
+ return isinstance(messages, list) and _messages_have_active_cleanup_prompt(messages)
+
+
+def _session_has_cleanup_prompt(*, cwd: str, session_id: str) -> bool:
+ try:
+ messages = SessionStorage().load(cwd, session_id)
+ except Exception:
+ logger.warning("Failed to inspect A2A session cleanup prompt", exc_info=True)
+ return False
+ return _messages_have_active_cleanup_prompt(messages)
+
+
+def _a2a_cleanup_prompt_exists(*, runtime: Any, cwd: str, session_id: str) -> bool:
+ return _runtime_has_cleanup_prompt(runtime) or _session_has_cleanup_prompt(cwd=cwd, session_id=session_id)
+
+
+def _a2a_cleanup_ledger_unavailable(
+ ledger: CleanupLedger | None,
+ *,
+ runtime: Any,
+ cwd: str,
+ session_id: str,
+) -> bool:
+ if not _a2a_cleanup_prompt_exists(runtime=runtime, cwd=cwd, session_id=session_id):
+ return False
+ if ledger is None:
+ return True
+ try:
+ if not ledger.path.exists():
+ return True
+ except Exception:
+ return True
+ return ledger.load_failed()
+
+
+def _a2a_deferred_cleanup_prompts_path(*, cwd: str, session_id: str) -> Path:
+ return SessionStorage().session_dir(cwd, session_id) / "a2a" / _DEFERRED_CLEANUP_PROMPTS_FILENAME
+
+
+def _read_a2a_deferred_cleanup_prompts(*, cwd: str, session_id: str) -> tuple[list[str], bool]:
+ path = _a2a_deferred_cleanup_prompts_path(cwd=cwd, session_id=session_id)
+ if not path.exists():
+ return [], False
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("Failed to load deferred A2A cleanup prompts", exc_info=True)
+ return [], True
+ raw_prompts = data.get("prompts") if isinstance(data, dict) else None
+ if not isinstance(raw_prompts, list):
+ raw_prompt = data.get("prompt") if isinstance(data, dict) else None
+ raw_prompts = [raw_prompt] if isinstance(raw_prompt, str) else []
+ return [prompt for prompt in raw_prompts if isinstance(prompt, str) and prompt.strip()], False
+
+
+def _load_a2a_deferred_cleanup_prompts(*, cwd: str, session_id: str) -> list[str]:
+ prompts, _load_failed = _read_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id)
+ return prompts
+
+
+def _save_a2a_deferred_cleanup_prompts(*, cwd: str, session_id: str, prompts: list[str]) -> None:
+ path = _a2a_deferred_cleanup_prompts_path(cwd=cwd, session_id=session_id)
+ if not prompts:
+ _clear_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id)
+ return
+ try:
+ ensure_private_dir(path.parent)
+ atomic_write_text(
+ path,
+ json.dumps({"prompts": prompts}, ensure_ascii=False, sort_keys=True),
+ )
+ ensure_private_file(path)
+ except OSError:
+ logger.warning("Failed to persist deferred A2A cleanup prompt", exc_info=True)
+
+
+def _append_a2a_deferred_cleanup_prompt(*, cwd: str, session_id: str, prompt: str) -> bool:
+ prompt = prompt.strip()
+ if not prompt:
+ return True
+ prompts, load_failed = _read_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id)
+ if load_failed:
+ return False
+ if prompts and _is_cleanup_continue_prompt(prompt):
+ prompts = [prompts[-1]]
+ else:
+ prompts = [prompt]
+ _save_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id, prompts=prompts)
+ return True
+
+
+def _clear_a2a_deferred_cleanup_prompts(*, cwd: str, session_id: str) -> None:
+ path = _a2a_deferred_cleanup_prompts_path(cwd=cwd, session_id=session_id)
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ return
+ except OSError:
+ logger.warning("Failed to clear deferred A2A cleanup prompts", exc_info=True)
+
+
+def _a2a_prompts_after_cleanup(*, cwd: str, session_id: str, prompt: str) -> tuple[list[str], bool] | None:
+ deferred_prompts, load_failed = _read_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id)
+ if load_failed:
+ return None
+ if not deferred_prompts:
+ return [prompt], False
+ if prompt.strip():
+ if not _append_a2a_deferred_cleanup_prompt(cwd=cwd, session_id=session_id, prompt=prompt):
+ return None
+ deferred_prompts, load_failed = _read_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id)
+ if load_failed:
+ return None
+ return deferred_prompts, True
+
+
+def _is_cleanup_continue_prompt(prompt: str) -> bool:
+ normalized = prompt.strip().lower()
+ return normalized in {"continue", "继续"}
+
+
+def _a2a_pipeline_state_for_session(
+ *,
+ cwd: str,
+ session_id: str,
+) -> tuple[A2APipelineSnapshotStore, A2APipelineJournal, dict[str, Any], list[dict[str, Any]] | None] | None:
+ try:
+ pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id)
+ snapshot_store = A2APipelineSnapshotStore(pipeline_dir)
+ journal = A2APipelineJournal(pipeline_dir)
+ snapshot = snapshot_store.load()
+ except Exception:
+ logger.warning("Failed to load A2A pipeline snapshot", exc_info=True)
+ return None
+ journal_events: list[dict[str, Any]] | None = None
+ if not isinstance(snapshot, dict):
+ try:
+ journal_events = journal.read_all_repairing_tail()
+ except Exception:
+ logger.warning("Failed to rebuild A2A pipeline snapshot from journal", exc_info=True)
+ return None
+ if not journal_events:
+ return None
+ snapshot = reduce_pipeline_events(journal_events)
+ return snapshot_store, journal, snapshot, journal_events
+
+
+def _prune_completed_cleanup_prompt_from_runtime(runtime: Any, ledger: CleanupLedger | None) -> None:
+ if ledger is None and _runtime_has_cleanup_prompt(runtime):
+ logger.warning("Keeping A2A cleanup prompt because cleanup ledger is unavailable")
+ return
+ if ledger is not None and ledger.load_failed():
+ logger.warning("Keeping A2A cleanup prompt because cleanup ledger is unreadable")
+ return
+ if ledger is not None and not ledger.path.exists() and _runtime_has_cleanup_prompt(runtime):
+ logger.warning("Keeping A2A cleanup prompt because cleanup ledger is unavailable")
+ return
+ if ledger is not None and ledger.pending_resources():
+ return
+ context_manager = getattr(getattr(runtime, "agent_loop", None), "context_manager", None)
+ remover = getattr(context_manager, "remove_cleanup_prompt_messages", None)
+ if not callable(remover):
+ return
+ try:
+ remover()
+ except Exception:
+ logger.warning("Failed to remove completed A2A cleanup prompt from context", exc_info=True)
+
+
+def _mark_completed_cleanup_prompts(
+ *,
+ runtime: Any,
+ cwd: str,
+ session_id: str,
+ ledger: CleanupLedger,
+) -> None:
+ ledger_path = getattr(ledger, "path", None)
+ context_manager = getattr(getattr(runtime, "agent_loop", None), "context_manager", None)
+ get_messages = getattr(context_manager, "get_messages", None)
+ if callable(get_messages):
+ try:
+ messages = get_messages()
+ except Exception:
+ messages = []
+ if isinstance(messages, list):
+ for message in messages:
+ mark_cleanup_prompt_message_completed(message, cleanup_ledger_path=ledger_path)
+
+ session_storage = SessionStorage()
+ try:
+ messages = session_storage.load(cwd, session_id)
+ except Exception:
+ logger.warning("Failed to load A2A session while marking cleanup prompt completed", exc_info=True)
+ return
+ changed = False
+ for message in messages:
+ changed = mark_cleanup_prompt_message_completed(message, cleanup_ledger_path=ledger_path) or changed
+ if not changed:
+ return
+ try:
+ session_storage.save(cwd, session_id, messages)
+ except Exception:
+ logger.warning("Failed to mark A2A cleanup prompt completed in session", exc_info=True)
+
+
+def _cleanup_publisher_for_a2a_normal_chat(
+ *,
+ event_queue: EventQueue,
+ cwd: str,
+ session_id: str,
+ task_id: str,
+ context_id: str,
+ artifact_store: Any | None,
+ exposure_types: Any,
+) -> PipelineA2AEventPublisher | None:
+ state = _a2a_pipeline_state_for_session(cwd=cwd, session_id=session_id)
+ if state is None:
+ return None
+ snapshot_store, journal, snapshot, journal_events = state
+
+ translator = PipelineEventTranslator(
+ PipelineA2AContext(
+ pipeline_run_id=_string_value(snapshot.get("pipelineRunId")) or context_id,
+ task_id=_string_value(snapshot.get("taskId")) or task_id,
+ context_id=_string_value(snapshot.get("contextId")) or context_id,
+ pipeline_name=_string_value(snapshot.get("pipelineName")) or "pipeline",
+ )
+ )
+ try:
+ if journal_events is None:
+ journal_events = journal.read_all_repairing_tail()
+ translator.hydrate_from_events(journal_events)
+ except Exception:
+ logger.warning("Failed to hydrate A2A cleanup event translator", exc_info=True)
+ return PipelineA2AEventPublisher(
+ event_queue,
+ translator,
+ journal,
+ snapshot_store,
+ artifact_store=artifact_store,
+ exposure_types=exposure_types,
+ delivery_task_id=task_id,
+ delivery_context_id=context_id,
+ )
+
+
+async def _observe_cleanup_stream(
+ events: AsyncIterator[Any],
+ ledger: CleanupLedger,
+ *,
+ publisher: PipelineA2AEventPublisher | None = None,
+) -> AsyncIterator[Any]:
+ if ledger.load_failed():
+ async for event in events:
+ yield event
+ return
+ observer = CleanupObserver(ledger)
+ previous = (
+ _published_cleanup_resource_states(publisher, ledger)
+ if publisher is not None
+ else _cleanup_resource_states(ledger)
+ )
+ if publisher is not None:
+ previous = await _publish_cleanup_resource_changes(publisher, ledger, previous)
+ async for event in events:
+ observer.observe(event)
+ if publisher is not None:
+ previous = await _publish_cleanup_resource_changes(publisher, ledger, previous)
+ yield event
+
+
+def _cleanup_resource_state(resource: Any) -> tuple[Any, ...]:
+ return (
+ getattr(resource, "cleanup_status", None),
+ getattr(resource, "progress_status", None),
+ getattr(resource, "progress_percentage", None),
+ getattr(resource, "cleanup_tool_use_id", None),
+ getattr(resource, "last_error", None),
+ )
+
+
+def _cleanup_resource_states(ledger: CleanupLedger) -> dict[str, tuple[Any, ...]]:
+ return {resource.key: _cleanup_resource_state(resource) for resource in ledger.cleanup_resources()}
+
+
+def _published_cleanup_resource_states(
+ publisher: PipelineA2AEventPublisher,
+ ledger: CleanupLedger,
+) -> dict[str, tuple[Any, ...]]:
+ snapshot_store = getattr(publisher, "snapshot_store", None)
+ load = getattr(snapshot_store, "load", None)
+ if not callable(load):
+ return {}
+ try:
+ snapshot = load()
+ except Exception:
+ logger.warning("Failed to load A2A cleanup snapshot state for catch-up", exc_info=True)
+ return {}
+ if not isinstance(snapshot, dict):
+ return {}
+ cleanup = snapshot.get("cleanup")
+ if not isinstance(cleanup, dict):
+ return {}
+ snapshot_resources = [item for item in cleanup.get("resources", []) if isinstance(item, dict)]
+ states: dict[str, tuple[Any, ...]] = {}
+ for resource in ledger.cleanup_resources():
+ match = _matching_snapshot_cleanup_resource(resource, snapshot_resources)
+ if match is not None:
+ states[resource.key] = _snapshot_cleanup_resource_state(match)
+ return states
+
+
+def _matching_snapshot_cleanup_resource(resource: Any, candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
+ for candidate in candidates:
+ if candidate.get("resourceId") != getattr(resource, "resource_id", None):
+ continue
+ if not _optional_cleanup_field_matches(candidate.get("regionId"), getattr(resource, "region_id", None)):
+ continue
+ if not _optional_cleanup_field_matches(candidate.get("provider"), getattr(resource, "provider", None)):
+ continue
+ resource_type = candidate.get("resourceType") or candidate.get("resource_type")
+ if not _optional_cleanup_field_matches(resource_type, getattr(resource, "resource_type", None)):
+ continue
+ return candidate
+ return None
+
+
+def _optional_cleanup_field_matches(snapshot_value: Any, ledger_value: Any) -> bool:
+ snapshot_text = snapshot_value if isinstance(snapshot_value, str) and snapshot_value else None
+ ledger_text = ledger_value if isinstance(ledger_value, str) and ledger_value else None
+ return snapshot_text is None or ledger_text is None or snapshot_text == ledger_text
+
+
+def _snapshot_cleanup_resource_state(resource: dict[str, Any]) -> tuple[Any, ...]:
+ return (
+ resource.get("cleanupStatus") or resource.get("cleanup_status") or resource.get("status"),
+ resource.get("progressStatus") or resource.get("stackStatus"),
+ resource.get("progressPercentage"),
+ resource.get("cleanupToolUseId") or resource.get("cleanup_tool_use_id"),
+ resource.get("lastError"),
+ )
+
+
+async def _publish_cleanup_resource_changes(
+ publisher: PipelineA2AEventPublisher,
+ ledger: CleanupLedger,
+ previous: dict[str, tuple[Any, ...]],
+) -> dict[str, tuple[Any, ...]]:
+ resources = ledger.cleanup_resources()
+ current = {resource.key: _cleanup_resource_state(resource) for resource in resources}
+ next_previous = dict(previous)
+ for resource in resources:
+ state = current.get(resource.key)
+ if state is None or previous.get(resource.key) == state:
+ continue
+ event_type = _cleanup_event_type_for_status(resource.cleanup_status)
+ if event_type is None:
+ continue
+ try:
+ published = await publisher.publish_manual(
+ event_type,
+ "cleanup",
+ status="working",
+ data=_cleanup_resource_event_data(resource, resource_count=len(resources)),
+ require_durable_metadata=True,
+ )
+ except Exception:
+ logger.warning("Failed to publish A2A cleanup progress event", exc_info=True)
+ continue
+ if published is not None:
+ next_previous[resource.key] = state
+ return next_previous
+
+
+def _cleanup_event_type_for_status(status: str) -> str | None:
+ if status == "started":
+ return PIPELINE_EVENT_CLEANUP_STARTED
+ if status == "in_progress":
+ return PIPELINE_EVENT_CLEANUP_PROGRESS
+ if status == "completed":
+ return PIPELINE_EVENT_CLEANUP_COMPLETED
+ if status == "failed":
+ return PIPELINE_EVENT_CLEANUP_FAILED
+ return None
+
+
+def _cleanup_resource_event_data(resource: Any, *, resource_count: int) -> dict[str, Any]:
+ data = {
+ "status": getattr(resource, "cleanup_status", None),
+ "resourceCount": resource_count,
+ "provider": getattr(resource, "provider", None),
+ "resourceType": getattr(resource, "resource_type", None),
+ "resourceId": getattr(resource, "resource_id", None),
+ "resourceName": getattr(resource, "resource_name", None),
+ "regionId": getattr(resource, "region_id", None),
+ "sourceStepId": getattr(resource, "source_step_id", None),
+ "cleanupStatus": getattr(resource, "cleanup_status", None),
+ "cleanupToolUseId": getattr(resource, "cleanup_tool_use_id", None),
+ "progressStatus": getattr(resource, "progress_status", None),
+ "progressPercentage": getattr(resource, "progress_percentage", None),
+ "stackStatus": getattr(resource, "progress_status", None),
+ "lastError": _public_cleanup_error(getattr(resource, "last_error", None)),
+ }
+ return {key: value for key, value in data.items() if value is not None}
+
+
+def _public_cleanup_error(value: Any) -> str | None:
+ if not value:
+ return None
+ text = sanitize_public_text(str(value))
+ return text[:_ERROR_TEXT_MAX_CHARS] + "..." if len(text) > _ERROR_TEXT_MAX_CHARS else text
+
+
+async def _stream_a2a_normal_events(
+ *,
+ runtime: Any,
+ prompt: str,
+ cleanup_ledger: CleanupLedger | None,
+ cleanup_publisher: PipelineA2AEventPublisher | None,
+ cwd: str,
+ session_id: str,
+) -> AsyncIterator[Any]:
+ if _a2a_cleanup_ledger_unavailable(cleanup_ledger, runtime=runtime, cwd=cwd, session_id=session_id):
+ if not _append_a2a_deferred_cleanup_prompt(cwd=cwd, session_id=session_id, prompt=prompt):
+ yield TextDeltaEvent(
+ text=_("Rollback cleanup deferred prompt state is unavailable. Please repair it before continuing.")
+ )
+ return
+ yield TextDeltaEvent(
+ text=_("Rollback cleanup state is unavailable. Please repair the cleanup ledger before continuing.")
+ )
+ return
+
+ if cleanup_ledger is not None and cleanup_ledger.load_failed():
+ if _runtime_has_cleanup_prompt(runtime) or _session_has_cleanup_prompt(cwd=cwd, session_id=session_id):
+ if not _append_a2a_deferred_cleanup_prompt(cwd=cwd, session_id=session_id, prompt=prompt):
+ yield TextDeltaEvent(
+ text=_("Rollback cleanup deferred prompt state is unavailable. Please repair it before continuing.")
+ )
+ return
+ yield TextDeltaEvent(
+ text=_("Rollback cleanup state is unavailable. Please repair the cleanup ledger before continuing.")
+ )
+ return
+
+ run_cleanup_continuation = (
+ cleanup_ledger is not None
+ and not cleanup_ledger.load_failed()
+ and bool(cleanup_ledger.pending_resources())
+ and callable(getattr(runtime.agent_loop, "continue_streaming", None))
+ )
+ if run_cleanup_continuation and cleanup_ledger is not None:
+ _ensure_cleanup_prompt_in_session(cwd=cwd, session_id=session_id, ledger=cleanup_ledger, runtime=runtime)
+ cleanup_stream = _observe_cleanup_stream(
+ runtime.agent_loop.continue_streaming(),
+ cleanup_ledger,
+ publisher=cleanup_publisher,
+ )
+ async for event in cleanup_stream:
+ yield event
+ if cleanup_ledger.pending_resources():
+ if not _append_a2a_deferred_cleanup_prompt(cwd=cwd, session_id=session_id, prompt=prompt):
+ yield TextDeltaEvent(
+ text=_("Rollback cleanup deferred prompt state is unavailable. Please repair it before continuing.")
+ )
+ return
+ yield TextDeltaEvent(
+ text=_("Rollback cleanup is still in progress. Please continue after cleanup completes.")
+ )
+ return
+ _mark_completed_cleanup_prompts(runtime=runtime, cwd=cwd, session_id=session_id, ledger=cleanup_ledger)
+ _prune_completed_cleanup_prompt_from_runtime(runtime, cleanup_ledger)
+
+ prompts_after_cleanup = _a2a_prompts_after_cleanup(cwd=cwd, session_id=session_id, prompt=prompt)
+ if prompts_after_cleanup is None:
+ yield TextDeltaEvent(
+ text=_("Rollback cleanup deferred prompt state is unavailable. Please repair it before continuing.")
+ )
+ return
+ prompts_to_run, has_deferred_prompts = prompts_after_cleanup
+ for prompt_to_run in prompts_to_run:
+ prompt_stream = runtime.agent_loop.run_streaming(prompt_to_run)
+ if cleanup_ledger is not None:
+ prompt_stream = _observe_cleanup_stream(prompt_stream, cleanup_ledger, publisher=cleanup_publisher)
+ async for event in prompt_stream:
+ yield event
+ if cleanup_ledger is not None and not cleanup_ledger.load_failed() and not cleanup_ledger.pending_resources():
+ _mark_completed_cleanup_prompts(runtime=runtime, cwd=cwd, session_id=session_id, ledger=cleanup_ledger)
+ _prune_completed_cleanup_prompt_from_runtime(runtime, cleanup_ledger)
+ if has_deferred_prompts:
+ _clear_a2a_deferred_cleanup_prompts(cwd=cwd, session_id=session_id)
+
+
+def _string_value(value: Any) -> str:
+ return value if isinstance(value, str) and value else ""
+
+
class IacCodeA2AExecutor(AgentExecutor):
def __init__(
self,
@@ -85,6 +782,15 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
task_id = requested_task_id or "task-" + uuid.uuid4().hex[:12]
context_id = context.context_id or "ctx-" + uuid.uuid4().hex[:12]
task = None
+ initial_task_published = False
+
+ async def publish_initial_task_if_missing() -> None:
+ nonlocal initial_task_published
+ if initial_task_published or isinstance(getattr(context, "current_task", None), Task):
+ return
+ await self._publish_initial_task(event_queue, task_id=task_id, context_id=context_id, context=context)
+ initial_task_published = True
+
try:
metadata = getattr(context, "metadata", None) or getattr(
getattr(context, "message", None), "metadata", None
@@ -94,8 +800,23 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
metadata_model = self._resolve_model(metadata)
model = metadata_model or self._model
aliyun_credential = self._resolve_aliyun_credential(metadata)
- prompt = self._prompt_from_context(context, cwd=cwd)
pipeline_mode = get_run_mode() == RunMode.PIPELINE
+ route_pipeline_handoff_to_normal = False
+ if pipeline_mode:
+ route_pipeline_handoff_to_normal = await self._should_route_pipeline_handoff_to_normal(
+ context_id=context_id,
+ cwd=cwd,
+ )
+ pipeline_input: PipelineUserInput | None = None
+ if pipeline_mode and not route_pipeline_handoff_to_normal:
+ try:
+ pipeline_input = self._pipeline_input_from_context(context, cwd=cwd)
+ except ValueError as exc:
+ raise InvalidParamsError(sanitize_public_text(str(exc))) from exc
+ prompt = pipeline_input.display_text
+ self._validate_pipeline_request_input(pipeline_input, model=model)
+ else:
+ prompt = self._prompt_from_context(context, cwd=cwd)
if pipeline_mode and requested_task_id is None:
recovered_task_id = await self._recoverable_pipeline_task_id_for_context(context_id=context_id, cwd=cwd)
if recovered_task_id is not None:
@@ -107,10 +828,12 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
owner=owner,
restore_interrupted=not pipeline_mode,
)
- if not isinstance(getattr(context, "current_task", None), Task):
- await self._publish_initial_task(event_queue, task_id=task_id, context_id=context_id, context=context)
+ await publish_initial_task_if_missing()
await self._task_store.ensure_task_not_expired(task.task_id)
+ except InvalidParamsError:
+ raise
except Exception as exc:
+ await publish_initial_task_if_missing()
if _is_retryable_executor_error(exc):
await self._publish_status(
event_queue,
@@ -140,7 +863,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
self._metrics.record_task_failed()
return
- if not prompt.strip():
+ if not (pipeline_mode and not route_pipeline_handoff_to_normal) and not prompt.strip():
task.state = TASK_STATE_FAILED
await self._publish_status(
event_queue,
@@ -154,11 +877,8 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
self._metrics.record_task_failed()
return
- route_pipeline_handoff_to_normal = pipeline_mode and await self._should_route_pipeline_handoff_to_normal(
- context_id=context_id,
- cwd=cwd,
- )
if pipeline_mode and not route_pipeline_handoff_to_normal:
+ assert pipeline_input is not None
pipeline_executor = IacCodeA2APipelineExecutor(
task_store=self._task_store,
model=model,
@@ -176,7 +896,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
task_id=task_id,
context_id=context_id,
cwd=cwd,
- prompt=prompt,
+ pipeline_input=pipeline_input,
)
return
if route_pipeline_handoff_to_normal:
@@ -288,7 +1008,28 @@ def runtime_factory(session_id: str) -> Any:
with use_session_id(ctx.session_id), user_id_ctx, aliyun_credential_ctx:
self._configure_runtime_model(runtime, model, from_metadata=metadata_model is not None)
self._refresh_runtime_cloud_tools(runtime)
- async for event in runtime.agent_loop.run_streaming(prompt):
+ cleanup_ledger = _cleanup_ledger_for_a2a_normal_chat(cwd=cwd, session_id=ctx.session_id)
+ _prune_completed_cleanup_prompt_from_runtime(runtime, cleanup_ledger)
+ cleanup_publisher = None
+ if cleanup_ledger is not None:
+ cleanup_publisher = _cleanup_publisher_for_a2a_normal_chat(
+ event_queue=event_queue,
+ cwd=cwd,
+ session_id=ctx.session_id,
+ task_id=task_id,
+ context_id=context_id,
+ artifact_store=self._artifact_store,
+ exposure_types=self._thinking_exposure_types,
+ )
+ stream = _stream_a2a_normal_events(
+ runtime=runtime,
+ prompt=prompt,
+ cleanup_ledger=cleanup_ledger,
+ cleanup_publisher=cleanup_publisher,
+ cwd=cwd,
+ session_id=ctx.session_id,
+ )
+ async for event in stream:
text_chunk = await publish_stream_event(
event_queue,
task_id=task_id,
@@ -477,6 +1218,43 @@ def _prompt_from_context(self, context: RequestContext, *, cwd: str) -> str:
return context.get_user_input()
return parts_to_prompt(message.parts, cwd=cwd)
+ def _pipeline_input_from_context(self, context: RequestContext, *, cwd: str) -> PipelineUserInput:
+ message = getattr(context, "message", None)
+ if not isinstance(message, Message):
+ return normalize_pipeline_user_input(context.get_user_input())
+ return parts_to_pipeline_input(message.parts, cwd=cwd)
+
+ def validate_pipeline_message_request(self, message: Message) -> None:
+ metadata = getattr(message, "metadata", None)
+ try:
+ cwd = self._resolve_cwd(metadata)
+ pipeline_input = parts_to_pipeline_input(message.parts, cwd=cwd)
+ except ValueError as exc:
+ raise InvalidParamsError(sanitize_public_text(str(exc))) from exc
+ model = self._resolve_model(metadata) or self._model
+ self._validate_pipeline_request_input(pipeline_input, model=model)
+
+ def _validate_pipeline_request_input(self, pipeline_input: PipelineUserInput, *, model: str | None = None) -> None:
+ if pipeline_input.is_empty:
+ raise InvalidParamsError("A2A server received empty input.")
+ model = model or self._model
+ if pipeline_input.has_images and not self._model_supports_image_input(model=model):
+ raise InvalidParamsError(_("Current model {model} does not support image input.").format(model=model))
+
+ def _model_supports_image_input(self, *, model: str | None = None) -> bool:
+ model = model or self._model
+ provider_key = get_active_provider_key()
+ provider_config = get_provider_config(provider_key) if provider_key else {}
+ api_base = provider_config.get("apiBase") if isinstance(provider_config.get("apiBase"), str) else None
+ credentials = load_credentials(model=model)
+ api_key = credentials.get(provider_key, "") if provider_key else None
+ return is_model_multimodal(
+ model,
+ provider_key=provider_key,
+ base_url=api_base,
+ api_key=api_key,
+ )
+
def _sanitize_error(self, exc: Exception) -> str:
if isinstance(exc, ValueError):
msg = str(exc).lower()
@@ -496,11 +1274,10 @@ async def _should_route_pipeline_handoff_to_normal(self, *, context_id: str, cwd
return False
if ctx.cwd != cwd:
return False
- snapshot = A2APipelineSnapshotStore(
- existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=ctx.session_id)
- ).load()
- if not isinstance(snapshot, dict):
+ state = _a2a_pipeline_state_for_session(cwd=cwd, session_id=ctx.session_id)
+ if state is None:
return False
+ _snapshot_store, _journal, snapshot, _journal_events = state
handoff = snapshot.get("normalHandoff")
if not isinstance(handoff, dict):
return False
@@ -513,26 +1290,47 @@ async def _ensure_pipeline_handoff_context_in_session(self, *, context_id: str,
return
if ctx.cwd != cwd:
return
- snapshot = A2APipelineSnapshotStore(
- existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=ctx.session_id)
- ).load()
- if not isinstance(snapshot, dict):
+ state = _a2a_pipeline_state_for_session(cwd=cwd, session_id=ctx.session_id)
+ if state is None:
return
+ _snapshot_store, _journal, snapshot, _journal_events = state
handoff = snapshot.get("normalHandoff")
if not isinstance(handoff, dict):
return
summary = handoff.get("summary")
- if not isinstance(summary, str) or not summary:
+ cleanup_payload = None
+ data = handoff.get("data")
+ if isinstance(data, dict) and isinstance(data.get("cleanup"), dict):
+ cleanup_payload = _cleanup_payload_from_private_ledger_or_unavailable(
+ ledger_path=_default_cleanup_ledger_path(cwd=cwd, session_id=ctx.session_id),
+ )
+ cleanup_prompt = cleanup_payload.get("prompt") if isinstance(cleanup_payload, dict) else None
+ cleanup_ledger_path = cleanup_payload.get("ledgerPath") if isinstance(cleanup_payload, dict) else None
+ if not isinstance(cleanup_prompt, str) or not cleanup_prompt:
+ cleanup_prompt = None
+ if not isinstance(cleanup_ledger_path, str) or not cleanup_ledger_path:
+ cleanup_ledger_path = None
+ if (not isinstance(summary, str) or not summary) and cleanup_prompt is None:
return
session_storage = SessionStorage()
messages = session_storage.load(cwd, ctx.session_id)
- if any(
- getattr(message, "role", None) == "user" and getattr(message, "content", None) == summary
- for message in messages
+ if isinstance(summary, str) and summary and not _session_has_user_message(messages, content=summary):
+ session_storage.append(cwd, ctx.session_id, AgentMessage(role="user", content=summary))
+ messages.append(AgentMessage(role="user", content=summary))
+ if cleanup_prompt is not None and not _session_has_active_cleanup_prompt_content(
+ messages,
+ content=cleanup_prompt,
):
- return
- session_storage.append(cwd, ctx.session_id, AgentMessage(role="user", content=summary))
+ session_storage.append(
+ cwd,
+ ctx.session_id,
+ create_cleanup_prompt_message(
+ cleanup_prompt,
+ cleanup_ledger_path=cleanup_ledger_path,
+ cleanup_status="pending" if cleanup_ledger_path else None,
+ ),
+ )
async def _recoverable_pipeline_task_id_for_context(self, *, context_id: str, cwd: str) -> str | None:
try:
diff --git a/src/iac_code/a2a/jsonrpc_passthrough.py b/src/iac_code/a2a/jsonrpc_passthrough.py
new file mode 100644
index 00000000..75898b1b
--- /dev/null
+++ b/src/iac_code/a2a/jsonrpc_passthrough.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+import logging
+from collections.abc import Awaitable, Callable
+from types import MethodType
+from typing import Any, AsyncIterable, AsyncIterator
+
+from a2a.server.context import ServerCallContext
+from jsonrpc.jsonrpc2 import JSONRPC20Response
+from sse_starlette.sse import EventSourceResponse
+from starlette.responses import Response
+
+logger = logging.getLogger(__name__)
+
+
+def install_jsonrpc_error_data_passthrough() -> None:
+ try:
+ from a2a.server.request_handlers import response_helpers
+ from a2a.server.routes import jsonrpc_dispatcher
+ except Exception:
+ return
+ current = response_helpers.build_error_response
+ if getattr(current, "_iac_code_recoverable_data_passthrough", False):
+ return
+ original = current
+
+ def build_error_response_with_passthrough(request_id: str | int | None, error: Any) -> dict[str, Any]:
+ if getattr(error, "jsonrpc_error_data_passthrough", False):
+ payload = {
+ "code": int(getattr(error, "code", -32603)),
+ "message": str(error),
+ }
+ data = getattr(error, "data", None)
+ if data is not None:
+ payload["data"] = data
+ return JSONRPC20Response(error=payload, _id=request_id).data
+ return original(request_id, error)
+
+ setattr(build_error_response_with_passthrough, "_iac_code_recoverable_data_passthrough", True)
+ setattr(response_helpers, "build_error_response", build_error_response_with_passthrough)
+ setattr(jsonrpc_dispatcher, "build_error_response", build_error_response_with_passthrough)
+
+
+def install_v03_jsonrpc_error_data_passthrough(jsonrpc_endpoint: Callable[..., Awaitable[Response]]) -> None:
+ dispatcher = getattr(jsonrpc_endpoint, "__self__", None)
+ adapter = getattr(dispatcher, "_v03_adapter", None)
+ if adapter is None or getattr(adapter, "_iac_code_recoverable_error_passthrough", False):
+ return
+
+ try:
+ from a2a.compat.v0_3 import types as types_v03
+ except Exception:
+ logger.debug("A2A v0.3 compatibility types are unavailable", exc_info=True)
+ return
+
+ async def _process_streaming_request_with_passthrough(
+ self: Any,
+ request_id: str | int | None,
+ request_obj: Any,
+ context: ServerCallContext,
+ ) -> EventSourceResponse:
+ method = request_obj.method
+ if method == "message/stream":
+ stream_gen = self.handler.on_message_send_stream(request_obj, context)
+ elif method == "tasks/resubscribe":
+ stream_gen = self.handler.on_subscribe_to_task(request_obj, context)
+ else:
+ raise ValueError(f"Unsupported streaming method {method}")
+
+ async def event_generator(stream: AsyncIterable[Any]) -> AsyncIterator[dict[str, str]]:
+ try:
+ async for item in stream:
+ yield {"data": item.model_dump_json(by_alias=True, exclude_none=True)}
+ except Exception as exc:
+ logger.exception("Error during stream generation in v0.3 JSONRPCAdapter")
+ if getattr(exc, "jsonrpc_error_data_passthrough", False):
+ error = types_v03.InvalidParamsError(message=str(exc), data=getattr(exc, "data", None))
+ else:
+ error = types_v03.InternalError(message=str(exc))
+ err_resp = types_v03.SendStreamingMessageResponse(
+ root=types_v03.JSONRPCErrorResponse(id=request_id, error=error)
+ )
+ yield {"data": err_resp.model_dump_json(by_alias=True, exclude_none=True)}
+
+ return EventSourceResponse(event_generator(stream_gen))
+
+ adapter._process_streaming_request = MethodType(_process_streaming_request_with_passthrough, adapter)
+ adapter._iac_code_recoverable_error_passthrough = True
diff --git a/src/iac_code/a2a/parts.py b/src/iac_code/a2a/parts.py
index 0b546cc2..d28fded1 100644
--- a/src/iac_code/a2a/parts.py
+++ b/src/iac_code/a2a/parts.py
@@ -13,6 +13,10 @@
from google.protobuf.json_format import MessageToDict
+from iac_code.agent.message import ContentBlock, ImageBlock, TextBlock
+from iac_code.pipeline.engine.user_input import PipelineUserInput, content_display_text, content_has_images
+from iac_code.utils.image.resizer import maybe_resize_and_downsample
+
MAX_INLINE_BYTES = 1024 * 1024
MAX_FILE_BYTES = 1024 * 1024
MAX_BINARY_INLINE_BYTES = 5 * 1024 * 1024
@@ -38,6 +42,7 @@
)
TEXT_LIKE_MIME_TYPES = frozenset(DEFAULT_TEXT_LIKE_MIME_TYPES)
MULTIMODAL_MIME_TYPES = frozenset(DEFAULT_MULTIMODAL_MIME_TYPES)
+SUPPORTED_IMAGE_MIME_TYPES = frozenset(("image/png", "image/jpeg", "image/webp", "image/gif"))
SUPPORTED_INPUT_MIME_TYPES = [*DEFAULT_TEXT_LIKE_MIME_TYPES, *DEFAULT_MULTIMODAL_MIME_TYPES]
@@ -108,6 +113,66 @@ def parts_to_prompt(message_parts: Iterable[Any], *, cwd: str | Path) -> str:
return "\n".join(value for value in values if value)
+def parts_to_pipeline_input(message_parts: Iterable[Any], *, cwd: str | Path) -> PipelineUserInput:
+ blocks: list[ContentBlock] = []
+ for part in message_parts:
+ converted = part_to_pipeline_block(part, cwd=cwd)
+ if isinstance(converted, list):
+ blocks.extend(converted)
+ elif converted:
+ blocks.append(TextBlock(text=converted))
+ if content_has_images(blocks):
+ return PipelineUserInput(
+ content=blocks,
+ display_text=content_display_text(blocks),
+ has_images=True,
+ )
+ text = "\n".join(block.text for block in blocks if isinstance(block, TextBlock))
+ return PipelineUserInput(content=text, display_text=text, has_images=False)
+
+
+def part_to_pipeline_block(part: Any, *, cwd: str | Path) -> str | list[ContentBlock]:
+ media_type = _media_type(part)
+ if _has_field(part, "text"):
+ _ensure_text_like(media_type)
+ return str(part.text)
+ if _has_field(part, "data"):
+ if media_type in SUPPORTED_IMAGE_MIME_TYPES:
+ return [_image_block_from_binary(_binary_data_part_bytes(part), requested_media_type=media_type)]
+ if _is_multimodal(media_type):
+ raise ValueError("A2A pipeline input has unsupported image media type.")
+ if media_type != "application/json":
+ raise ValueError("A2A data parts must use application/json media type.")
+ data = MessageToDict(part.data, preserving_proto_field_name=False)
+ serialized = json.dumps(data, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
+ _ensure_size(serialized.encode("utf-8"), limit=MAX_INLINE_BYTES, label="A2A data part")
+ return serialized
+ if _has_field(part, "raw"):
+ raw = bytes(part.raw)
+ if media_type in SUPPORTED_IMAGE_MIME_TYPES:
+ _ensure_size(raw, limit=MAX_BINARY_INLINE_BYTES, label="A2A binary raw part")
+ return [_image_block_from_binary(raw, requested_media_type=media_type)]
+ if _is_multimodal(media_type):
+ raise ValueError("A2A pipeline input has unsupported image media type.")
+ _ensure_text_like(media_type)
+ _ensure_size(raw, limit=MAX_INLINE_BYTES, label="A2A raw part")
+ try:
+ return raw.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise ValueError("A2A raw parts must contain valid UTF-8.") from exc
+ if _has_field(part, "url"):
+ if media_type in SUPPORTED_IMAGE_MIME_TYPES:
+ path = _safe_file_url_path(str(part.url), cwd=Path(cwd))
+ if path.stat().st_size > MAX_BINARY_FILE_BYTES:
+ raise ValueError("A2A binary file URL part content is too large.")
+ return [_image_block_from_binary(path.read_bytes(), requested_media_type=media_type)]
+ if _is_multimodal(media_type):
+ raise ValueError("A2A pipeline input has unsupported image media type.")
+ _ensure_text_like(media_type)
+ return _read_file_url_part(str(part.url), cwd=Path(cwd))
+ raise ValueError("A2A server supports text, JSON data, raw text, or workspace file URL parts only.")
+
+
def part_to_prompt(part: Any, *, cwd: str | Path) -> str:
media_type = _media_type(part)
if _has_field(part, "text"):
@@ -188,6 +253,20 @@ def _filename(part: Any) -> str:
def _binary_data_part_to_manifest(part: Any, *, media_type: str) -> str:
+ data = MessageToDict(part.data, preserving_proto_field_name=False)
+ if not isinstance(data, dict):
+ raise ValueError("A2A binary data parts must contain an object.")
+ content = _binary_data_part_bytes(part)
+ filename = str(data.get("filename") or _filename(part) or "inline")
+ return _multimodal_manifest(
+ filename=os.path.basename(filename),
+ media_type=media_type,
+ content=content,
+ source="data",
+ )
+
+
+def _binary_data_part_bytes(part: Any) -> bytes:
data = MessageToDict(part.data, preserving_proto_field_name=False)
if not isinstance(data, dict):
raise ValueError("A2A binary data parts must contain an object.")
@@ -199,12 +278,16 @@ def _binary_data_part_to_manifest(part: Any, *, media_type: str) -> str:
except (ValueError, UnicodeEncodeError) as exc:
raise ValueError("A2A binary data part bytes must be valid base64.") from exc
_ensure_size(content, limit=MAX_BINARY_INLINE_BYTES, label="A2A binary data part")
- filename = str(data.get("filename") or _filename(part) or "inline")
- return _multimodal_manifest(
- filename=os.path.basename(filename),
- media_type=media_type,
- content=content,
- source="data",
+ return content
+
+
+def _image_block_from_binary(raw: bytes, *, requested_media_type: str) -> ImageBlock:
+ if requested_media_type not in SUPPORTED_IMAGE_MIME_TYPES:
+ raise ValueError("A2A pipeline input has unsupported image media type.")
+ resized = maybe_resize_and_downsample(raw)
+ return ImageBlock(
+ media_type=resized.media_type,
+ data=base64.b64encode(resized.data).decode("ascii"),
)
diff --git a/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py
index d206c967..ff011887 100644
--- a/src/iac_code/a2a/pipeline_events.py
+++ b/src/iac_code/a2a/pipeline_events.py
@@ -40,11 +40,24 @@
"from_step": "fromStep",
"parent_step_id": "parentStepId",
"pipeline_type": "pipelineType",
+ "progress_status": "progressStatus",
"rollback_target": "rollbackTarget",
+ "cleanup_status": "cleanupStatus",
+ "cleanup_tool_use_id": "cleanupToolUseId",
+ "last_error": "lastError",
+ "progress_percentage": "progressPercentage",
+ "resource_count": "resourceCount",
+ "resource_id": "resourceId",
+ "resource_name": "resourceName",
+ "resource_type": "resourceType",
+ "region_id": "regionId",
"selected_index": "selectedIndex",
"selected_option": "selectedOption",
"selected_value": "selectedValue",
+ "source_step_id": "sourceStepId",
"stale_fields": "staleFields",
+ "stack_status": "stackStatus",
+ "status_message": "statusMessage",
"step_id": "stepId",
"step_index": "stepIndex",
"step_names": "stepNames",
@@ -325,6 +338,16 @@ def _translate_pipeline_event(self, event: PipelineEvent) -> list[dict[str, Any]
return [self._envelope("pipeline_started", "pipeline", "working", _event_data(data), created_at=created_at)]
if event.type == PipelineEventType.PIPELINE_RESUMED:
return [self._envelope("pipeline_resumed", "pipeline", "working", _event_data(data), created_at=created_at)]
+ if event.type == PipelineEventType.PIPELINE_WARNING:
+ return [
+ self._envelope(
+ "pipeline_warning",
+ "pipeline",
+ "working",
+ _warning_event_data(data),
+ created_at=created_at,
+ )
+ ]
if event.type == PipelineEventType.PIPELINE_COMPLETED:
event_type = "pipeline_failed" if data.get("failed") is True else "pipeline_completed"
status = "failed" if event_type == "pipeline_failed" else "completed"
@@ -541,7 +564,7 @@ def _translate_sub_pipeline_stream_event(self, event: SubPipelineStreamEvent) ->
return envelopes
def _translate_text_delta_event(self, event: TextDeltaEvent) -> dict[str, Any]:
- return self._envelope("text_delta", "pipeline", "working", {"text": event.text})
+ return self._translate_parent_scoped_display_event("text_delta", {"text": event.text})
def _translate_ask_user_question_event(self, event: AskUserQuestionEvent) -> dict[str, Any]:
envelope = self._translate_parent_scoped_display_event("input_required", _ask_user_question_data(event))
@@ -550,7 +573,7 @@ def _translate_ask_user_question_event(self, event: AskUserQuestionEvent) -> dic
return envelope
def _translate_permission_request_event(self, event: PermissionRequestEvent) -> dict[str, Any]:
- envelope = self._envelope("permission_requested", "pipeline", "working", _permission_request_data(event))
+ envelope = self._translate_parent_scoped_display_event("permission_requested", _permission_request_data(event))
envelope["permission"] = _permission_request_metadata(event)
return envelope
@@ -586,7 +609,7 @@ def _translate_tool_result_event(self, event: ToolResultEvent) -> list[dict[str,
stack_envelope = self._translate_stack_current_changed_event(event)
if stack_envelope is not None:
envelopes.append(stack_envelope)
- envelopes.append(self._envelope("tool_result", "pipeline", "working", _tool_result_data(event)))
+ envelopes.append(self._translate_parent_scoped_display_event("tool_result", _tool_result_data(event)))
return envelopes
def _remember_tool_input(self, event: ToolUseEndEvent) -> None:
@@ -662,6 +685,11 @@ def _stack_current_changed_data(self, event: ToolResultEvent) -> dict[str, Any]
if stack_id is None:
return None
+ stack_status = _first_string_from_sources((result,), ("StackStatus", "stackStatus", "status"))
+ is_delete_complete = action in _STACK_CLEAR_ACTIONS and is_success and stack_status == "DELETE_COMPLETE"
+ if action in _STACK_CLEAR_ACTIONS and is_success and stack_status is None:
+ stack_status = "DELETE_REQUESTED"
+
data: dict[str, Any] = {
"toolName": event.tool_name,
"toolUseId": event.tool_use_id,
@@ -670,11 +698,11 @@ def _stack_current_changed_data(self, event: ToolResultEvent) -> dict[str, Any]
"regionId": operation["regionId"],
"stackId": stack_id,
"stackName": _first_string_from_sources((result, params), ("StackName", "stackName", "stack_name", "name")),
- "stackStatus": _first_string_from_sources((result,), ("StackStatus", "stackStatus", "status")),
+ "stackStatus": stack_status,
"isSuccess": is_success,
- "current": False if action in _STACK_CLEAR_ACTIONS and is_success else True,
+ "current": False if is_delete_complete else True,
}
- if action in _STACK_CLEAR_ACTIONS and is_success:
+ if is_delete_complete:
data["cleared"] = True
return {key: value for key, value in data.items() if value is not None}
@@ -944,6 +972,11 @@ def _event_data(data: dict[str, Any]) -> dict[str, Any]:
}
+def _warning_event_data(data: dict[str, Any]) -> dict[str, Any]:
+ private_keys = {"ledger_path", "ledgerPath", "load_error", "loadError"}
+ return _event_data({key: value for key, value in data.items() if str(key) not in private_keys})
+
+
def _sanitize_event_value(key: str, value: Any) -> Any:
key_lower = key.lower()
if isinstance(value, str):
diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py
index 02665789..eef50b42 100644
--- a/src/iac_code/a2a/pipeline_executor.py
+++ b/src/iac_code/a2a/pipeline_executor.py
@@ -10,6 +10,7 @@
import httpx
from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent
+from a2a.utils.errors import InvalidParamsError
from iac_code.a2a.events import make_text_part
from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator
@@ -30,15 +31,20 @@
)
from iac_code.agent.message import Message as AgentMessage
from iac_code.i18n import _
-from iac_code.pipeline import create_pipeline
+from iac_code.pipeline import create_pipeline, discover_pipelines
from iac_code.pipeline.config import get_pipeline_name
+from iac_code.pipeline.engine.cleanup import CleanupLedger
from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType
-from iac_code.pipeline.engine.handoff import terminal_outcome_from_completed_event
+from iac_code.pipeline.engine.handoff import build_handoff_summary, terminal_outcome_from_completed_event
+from iac_code.pipeline.engine.loader import load_pipeline_dir
from iac_code.pipeline.engine.public_errors import public_error
+from iac_code.pipeline.engine.session import PipelineSession
+from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input
from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime
from iac_code.services.session_storage import SessionStorage
from iac_code.services.telemetry import use_session_id
from iac_code.types.stream_events import AskUserQuestionEvent, SubPipelineStreamEvent
+from iac_code.utils.public_errors import sanitize_public_text
logger = logging.getLogger(__name__)
_CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1
@@ -69,6 +75,27 @@ def _auth_error_text() -> str:
return _("Authentication required. Configure credentials and retry.")
+class RecoverablePipelineInvalidParamsError(InvalidParamsError):
+ code = -32602
+ jsonrpc_error_data_passthrough = True
+
+
+def _active_sidecar_mismatch_error(
+ *,
+ recoverable_task_id: str,
+ context_id: str,
+ sidecar_status: str,
+) -> RecoverablePipelineInvalidParamsError:
+ return RecoverablePipelineInvalidParamsError(
+ _("Pipeline already running. Resume task {task_id}.").format(task_id=recoverable_task_id),
+ data={
+ "recoverableTaskId": recoverable_task_id,
+ "contextId": context_id,
+ "sidecarStatus": sidecar_status,
+ },
+ )
+
+
@dataclass
class A2APipelineRuntime:
agent_runtime: Any
@@ -156,8 +183,13 @@ async def execute(
task_id: str,
context_id: str,
cwd: str,
- prompt: str,
+ pipeline_input: PipelineUserInput | str | None = None,
+ prompt: str | None = None,
) -> None:
+ if pipeline_input is None:
+ pipeline_input = prompt or ""
+ pipeline_input = normalize_pipeline_user_input(pipeline_input)
+ prompt = pipeline_input.display_text
session_storage = SessionStorage()
def runtime_factory(session_id: str) -> Any:
@@ -192,7 +224,7 @@ def runtime_factory(session_id: str) -> Any:
task_id=task_id,
context_id=context_id,
cwd=cwd,
- prompt=prompt,
+ pipeline_input=pipeline_input,
preserve_task_record=preserve_active_task,
)
if routed:
@@ -215,11 +247,7 @@ def runtime_factory(session_id: str) -> Any:
try:
owner_task = asyncio.current_task()
- ctx.active_task_id = task.task_id
- task.active_task = owner_task
- task.state = TASK_STATE_WORKING
- self._task_store.mirror_task(task)
- self._task_store.mirror_context(ctx)
+ task_persistence_started = False
pipeline = None
publisher: PipelineA2AEventPublisher | None = None
@@ -267,6 +295,7 @@ def fresh_pipeline_factory() -> Any:
selected = self._select_stream(
pipeline,
prompt,
+ pipeline_input=pipeline_input,
publisher=publisher,
task_id=task_id,
context_id=context_id,
@@ -277,6 +306,12 @@ def fresh_pipeline_factory() -> Any:
pipeline_runtime.pipeline = pipeline
self._task_store.mirror_context(ctx)
stream = selected.stream
+ ctx.active_task_id = task.task_id
+ task.active_task = owner_task
+ task.state = TASK_STATE_WORKING
+ task_persistence_started = True
+ self._task_store.mirror_task(task)
+ self._task_store.mirror_context(ctx)
stream_had_events = False
with use_session_id(ctx.session_id):
while True:
@@ -291,7 +326,7 @@ def fresh_pipeline_factory() -> Any:
if not stream_result.restart_requested:
break
- stream = self._continue_after_interrupt_stream(pipeline, prompt)
+ stream = self._continue_after_interrupt_stream(pipeline, pipeline_input)
terminal_status_published = False
terminal_sidecar = _is_terminal_sidecar_status(getattr(pipeline, "sidecar_status", None))
@@ -352,7 +387,10 @@ def fresh_pipeline_factory() -> Any:
)
await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state)
self._record_state(task.state)
+ except RecoverablePipelineInvalidParamsError:
+ raise
except Exception as exc:
+ task_persistence_started = True
await self._publish_exception_status(
event_queue,
task=task,
@@ -367,8 +405,9 @@ def fresh_pipeline_factory() -> Any:
if ctx.active_task_id == task.task_id:
ctx.active_task_id = None
ctx.touch()
- task.touch()
- self._task_store.mirror_task(task)
+ if task_persistence_started:
+ task.touch()
+ self._task_store.mirror_task(task)
self._task_store.mirror_context(ctx)
await _flush_telemetry_safely()
finally:
@@ -392,15 +431,29 @@ async def _route_active_pipeline_interrupt(
task_id: str,
context_id: str,
cwd: str,
- prompt: str,
+ pipeline_input: PipelineUserInput,
preserve_task_record: bool,
) -> bool:
+ pipeline_input = normalize_pipeline_user_input(pipeline_input)
+ prompt = pipeline_input.display_text
runtime = ctx.runtime
pipeline = getattr(runtime, "pipeline", None)
if pipeline is None:
return False
- pending_question_route = await self._route_pending_question_answer(runtime, prompt)
+ try:
+ pending_question_route = await self._route_pending_question_answer(runtime, pipeline_input)
+ except Exception as exc:
+ await self._publish_exception_status(
+ event_queue,
+ task=task,
+ task_id=task_id,
+ context_id=context_id,
+ exc=exc,
+ preserve_task_record=preserve_task_record,
+ pipeline_publisher=getattr(runtime, "publisher", None),
+ )
+ return True
if pending_question_route == _PENDING_QUESTION_ANSWERED:
task.state = TASK_STATE_WORKING
self._task_store.mirror_task(task)
@@ -442,7 +495,7 @@ async def _route_active_pipeline_interrupt(
task_id=task_id,
context_id=context_id,
session_id=ctx.session_id,
- prompt=prompt,
+ pipeline_input=pipeline_input,
preserve_task_record=preserve_task_record,
)
return True
@@ -465,12 +518,21 @@ async def _route_active_pipeline_interrupt(
await _maybe_await(pause_agent_loops())
paused = True
- verdict = await _maybe_await(handler(prompt))
+ runner_input = _pipeline_runner_input(pipeline_input)
+ verdict = await _maybe_await(handler(runner_input))
parent_rollback: bool | None = None
if getattr(verdict, "action", "") == "hard_interrupt":
apply_hard_interrupt = getattr(pipeline, "apply_hard_interrupt", None)
if callable(apply_hard_interrupt):
- parent_rollback = bool(await _maybe_await(apply_hard_interrupt(verdict)))
+ parameters = inspect.signature(apply_hard_interrupt).parameters
+ if pipeline_input.has_images and (
+ "source_input" in parameters
+ or any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values())
+ ):
+ applied = apply_hard_interrupt(verdict, source_input=runner_input)
+ else:
+ applied = apply_hard_interrupt(verdict)
+ parent_rollback = bool(await _maybe_await(applied))
if parent_rollback:
runtime.restart_after_interrupt = True
_restart_requested_event(runtime).set()
@@ -537,9 +599,11 @@ async def _continue_active_pause_confirmation(
task_id: str,
context_id: str,
session_id: str,
- prompt: str,
+ pipeline_input: PipelineUserInput,
preserve_task_record: bool,
) -> None:
+ pipeline_input = normalize_pipeline_user_input(pipeline_input)
+ prompt = pipeline_input.display_text
owner_task = asyncio.current_task()
task.active_task = owner_task
ctx.active_task_id = task_id
@@ -552,7 +616,10 @@ async def _continue_active_pause_confirmation(
self._task_store.mirror_task(task)
self._task_store.mirror_context(ctx)
try:
- stream = pipeline.continue_from_sidecar(user_input=prompt) if prompt else pipeline.continue_from_sidecar()
+ if prompt:
+ stream = pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input))
+ else:
+ stream = pipeline.continue_from_sidecar()
task.state = TASK_STATE_WORKING
self._task_store.mirror_task(task)
with use_session_id(session_id):
@@ -565,7 +632,7 @@ async def _continue_active_pause_confirmation(
)
if not stream_result.restart_requested:
break
- stream = self._continue_after_interrupt_stream(pipeline, prompt)
+ stream = self._continue_after_interrupt_stream(pipeline, pipeline_input)
snapshot = publisher.snapshot_store.load() or {}
task.state = _task_state_from_pipeline(pipeline, snapshot)
@@ -609,6 +676,7 @@ def _create_pipeline(
session_id=session_id,
cwd=cwd,
resume_from_sidecar=resume_from_sidecar,
+ surface="a2a",
)
def _set_pipeline_telemetry_correlation(self, pipeline: Any, *, task_id: str, context_id: str) -> None:
@@ -620,11 +688,11 @@ def _set_pipeline_telemetry_correlation(self, pipeline: Any, *, task_id: str, co
except Exception:
logger.warning("A2A pipeline telemetry correlation setup failed", exc_info=True)
- def _continue_after_interrupt_stream(self, pipeline: Any, prompt: str) -> AsyncIterator[Any]:
+ def _continue_after_interrupt_stream(self, pipeline: Any, pipeline_input: PipelineUserInput) -> AsyncIterator[Any]:
continue_after_interrupt = getattr(pipeline, "continue_after_interrupt", None)
if callable(continue_after_interrupt):
return continue_after_interrupt()
- return pipeline.run(prompt)
+ return pipeline.run(_pipeline_runner_input(pipeline_input))
async def _consume_stream_until_restart(
self,
@@ -757,6 +825,7 @@ def _select_stream(
pipeline: Any,
prompt: str,
*,
+ pipeline_input: PipelineUserInput,
publisher: PipelineA2AEventPublisher,
task_id: str,
context_id: str,
@@ -766,8 +835,11 @@ def _select_stream(
if status == "waiting_input":
_raise_if_sidecar_restore_failed(pipeline, status)
if not _sidecar_matches_task(publisher, task_id=task_id, context_id=context_id, sidecar_status=status):
- pipeline = self._fresh_pipeline_after_sidecar_mismatch(pipeline, fresh_pipeline_factory)
- return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.run(prompt))
+ raise _active_sidecar_mismatch_error_from_publisher(
+ publisher,
+ context_id=context_id,
+ sidecar_status=status,
+ )
pending_ask = _pending_ask_input_from_sidecar(
publisher,
task_id=task_id,
@@ -781,6 +853,7 @@ def _select_stream(
publisher=publisher,
pending_input=pending_ask,
prompt=prompt,
+ pipeline_input=pipeline_input,
),
)
pending_pause = _pending_pipeline_pause_input_from_sidecar(
@@ -790,15 +863,23 @@ def _select_stream(
)
if pending_pause is not None:
stream = (
- pipeline.continue_from_sidecar(user_input=prompt) if prompt else pipeline.continue_from_sidecar()
+ pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input))
+ if prompt
+ else pipeline.continue_from_sidecar()
)
return _SelectedPipelineStream(pipeline=pipeline, stream=stream)
- return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.resume(prompt))
+ return _SelectedPipelineStream(
+ pipeline=pipeline,
+ stream=pipeline.resume(_pipeline_runner_input(pipeline_input)),
+ )
if status == "running":
_raise_if_sidecar_restore_failed(pipeline, status)
if not _sidecar_matches_task(publisher, task_id=task_id, context_id=context_id, sidecar_status=status):
- pipeline = self._fresh_pipeline_after_sidecar_mismatch(pipeline, fresh_pipeline_factory)
- return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.run(prompt))
+ raise _active_sidecar_mismatch_error_from_publisher(
+ publisher,
+ context_id=context_id,
+ sidecar_status=status,
+ )
pending_ask = _pending_ask_input_from_sidecar(
publisher,
task_id=task_id,
@@ -812,6 +893,7 @@ def _select_stream(
publisher=publisher,
pending_input=pending_ask,
prompt=prompt,
+ pipeline_input=pipeline_input,
),
)
pending_pause = _pending_pipeline_pause_input_from_sidecar(
@@ -821,20 +903,29 @@ def _select_stream(
)
if pending_pause is not None:
stream = (
- pipeline.continue_from_sidecar(user_input=prompt) if prompt else pipeline.continue_from_sidecar()
+ pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input))
+ if prompt
+ else pipeline.continue_from_sidecar()
)
return _SelectedPipelineStream(pipeline=pipeline, stream=stream)
if prompt:
return _SelectedPipelineStream(
- pipeline=pipeline, stream=pipeline.continue_from_sidecar(user_input=prompt)
+ pipeline=pipeline,
+ stream=pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input)),
)
return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.continue_from_sidecar())
if status in _TERMINAL_SIDECAR_STATUSES:
if _terminal_sidecar_matches_task(publisher, status, task_id=task_id, context_id=context_id):
return _SelectedPipelineStream(pipeline=pipeline, stream=_empty_stream())
pipeline = self._fresh_pipeline_after_sidecar_mismatch(pipeline, fresh_pipeline_factory)
- return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.run(prompt))
- return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.run(prompt))
+ return _SelectedPipelineStream(
+ pipeline=pipeline,
+ stream=pipeline.run(_pipeline_runner_input(pipeline_input)),
+ )
+ return _SelectedPipelineStream(
+ pipeline=pipeline,
+ stream=pipeline.run(_pipeline_runner_input(pipeline_input)),
+ )
def _fresh_pipeline_after_sidecar_mismatch(
self,
@@ -956,16 +1047,21 @@ async def _publish_normal_handoff_ready(
logger.warning("Failed to build A2A pipeline normal handoff event", exc_info=True)
return
+ data = {
+ "action": "switch_to_normal",
+ "targetMode": "normal",
+ "outcome": outcome,
+ "summary": summary,
+ }
+ cleanup = _pipeline_cleanup_handoff_data(pipeline)
+ if cleanup is not None:
+ data["cleanup"] = cleanup
+
published = await publisher.publish_manual(
"pipeline_handoff_ready",
"pipeline",
status=_handoff_status_from_outcome(outcome),
- data={
- "action": "switch_to_normal",
- "targetMode": "normal",
- "outcome": outcome,
- "summary": summary,
- },
+ data=data,
)
if published is not None:
_persist_normal_handoff_summary(pipeline, summary)
@@ -986,7 +1082,9 @@ def _track_pending_question(
return
runtime.pending_question = _PendingAskUserQuestion(event=question, envelope=dict(envelope))
- async def _route_pending_question_answer(self, runtime: Any, prompt: str) -> str:
+ async def _route_pending_question_answer(self, runtime: Any, pipeline_input: PipelineUserInput) -> str:
+ pipeline_input = normalize_pipeline_user_input(pipeline_input)
+ prompt = pipeline_input.display_text
pending = getattr(runtime, "pending_question", None)
if not isinstance(pending, _PendingAskUserQuestion):
return _PENDING_QUESTION_NOT_ROUTED
@@ -998,11 +1096,12 @@ async def _route_pending_question_answer(self, runtime: Any, prompt: str) -> str
return _PENDING_QUESTION_STALE_FINISHED
publisher = getattr(runtime, "publisher", None)
- if not isinstance(publisher, PipelineA2AEventPublisher):
+ publish_manual = getattr(publisher, "publish_manual", None)
+ if not callable(publish_manual):
return _PENDING_QUESTION_NOT_ROUTED
answer = _ask_user_question_answer_from_prompt(question, prompt)
- published = await publisher.publish_manual(
+ published = await publish_manual(
"input_received",
str(pending.envelope.get("scope") or "pipeline"),
status="working",
@@ -1020,10 +1119,56 @@ async def _route_pending_question_answer(self, runtime: Any, prompt: str) -> str
if published is None:
return _PENDING_QUESTION_NOT_ROUTED
+ if pipeline_input.has_images:
+ inject_pending_question_supplement = getattr(
+ getattr(runtime, "pipeline", None),
+ "inject_pending_question_supplement",
+ None,
+ )
+ if callable(inject_pending_question_supplement):
+ try:
+ injected = inject_pending_question_supplement(pipeline_input.content, envelope=pending.envelope)
+ if inspect.isawaitable(injected):
+ injected = await injected
+ except Exception:
+ await self._restore_pending_question_input_required(runtime, pending)
+ raise
+ if injected is False:
+ await self._restore_pending_question_input_required(runtime, pending)
+ raise RuntimeError("A2A ask_user_question image supplement could not be delivered.")
+ else:
+ await self._restore_pending_question_input_required(runtime, pending)
+ raise RuntimeError("A2A pipeline cannot accept ask_user_question image supplement.")
future.set_result(answer)
runtime.pending_question = None
return _PENDING_QUESTION_ANSWERED
+ async def _restore_pending_question_input_required(self, runtime: Any, pending: "_PendingAskUserQuestion") -> None:
+ publisher = getattr(runtime, "publisher", None)
+ publish_manual = getattr(publisher, "publish_manual", None)
+ if not callable(publish_manual):
+ return
+ question = pending.event
+ envelope = pending.envelope if isinstance(pending.envelope, dict) else {}
+ data = {
+ "kind": "ask_user_question",
+ "inputId": _pending_input_id(envelope, question),
+ "toolUseId": question.tool_use_id,
+ "question": question.question,
+ "prompt": question.question,
+ "options": question.options if isinstance(question.options, list) else [],
+ "allowFreeText": question.allow_free_text,
+ "freeTextPrompt": question.free_text_prompt,
+ "required": True,
+ }
+ await publish_manual(
+ "input_required",
+ str(envelope.get("scope") or "pipeline"),
+ status="input_required",
+ data=data,
+ coordinates=_coordinates_from_envelope(envelope),
+ )
+
async def _fail_already_active(
self,
event_queue: Any,
@@ -1143,13 +1288,19 @@ async def _empty_stream() -> AsyncIterator[Any]:
yield None
+def _pipeline_runner_input(pipeline_input: PipelineUserInput) -> PipelineUserInput | str:
+ return pipeline_input if pipeline_input.has_images else pipeline_input.display_text
+
+
async def _resume_pending_ask_user_question_stream(
*,
pipeline: Any,
publisher: PipelineA2AEventPublisher,
pending_input: dict[str, Any],
prompt: str,
+ pipeline_input: PipelineUserInput,
) -> AsyncIterator[Any]:
+ pipeline_input = normalize_pipeline_user_input(pipeline_input)
resume_ask_user_question = getattr(pipeline, "resume_ask_user_question", None)
if not callable(resume_ask_user_question):
raise RuntimeError("Pipeline cannot resume pending ask_user_question input.")
@@ -1180,6 +1331,8 @@ async def _resume_pending_ask_user_question_stream(
parameters = inspect.signature(resume_ask_user_question).parameters
resume_kwargs: dict[str, Any] = {"tool_use_id": tool_use_id}
+ if pipeline_input.has_images:
+ resume_kwargs["supplemental_input"] = pipeline_input
if "pending_input" in parameters or any(
parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()
):
@@ -1382,8 +1535,23 @@ def cancel_waiting_input_task_from_sidecar(
)
if int(envelope.get("sequence") or 0) <= high_water_sequence:
envelope["sequence"] = high_water_sequence + 1
+ handoff_envelope = _waiting_input_cancel_handoff_event(
+ translator,
+ snapshot=snapshot,
+ cwd=cwd,
+ session_id=session_id,
+ pipeline_name=pipeline_name,
+ reason=reason,
+ )
+ if handoff_envelope is not None and int(handoff_envelope.get("sequence") or 0) <= int(
+ envelope.get("sequence") or 0
+ ):
+ handoff_envelope["sequence"] = int(envelope.get("sequence") or 0) + 1
try:
- journal.append(envelope)
+ events_to_append = [envelope]
+ if handoff_envelope is not None:
+ events_to_append.append(handoff_envelope)
+ journal.append_many(events_to_append, durable=True)
snapshot_store.save(reduce_pipeline_events(journal.read_all_repairing_tail()))
except Exception:
logger.warning("Failed to persist waiting A2A pipeline cancellation", exc_info=True)
@@ -1391,6 +1559,106 @@ def cancel_waiting_input_task_from_sidecar(
return True
+def _waiting_input_cancel_handoff_event(
+ translator: PipelineEventTranslator,
+ *,
+ snapshot: dict[str, Any] | None,
+ cwd: str,
+ session_id: str,
+ pipeline_name: str,
+ reason: str,
+) -> dict[str, Any] | None:
+ loaded_pipeline = _load_pipeline_definition_for_handoff(pipeline_name)
+ if loaded_pipeline is None:
+ return None
+ policy = getattr(loaded_pipeline, "on_complete", None)
+ if policy is None or policy.action != "switch_to_normal" or "canceled" not in policy.apply_on:
+ return None
+
+ include_fields = getattr(policy.handoff_context, "include", [])
+ context_snapshot = _flat_pipeline_context_from_sidecar(cwd=cwd, session_id=session_id)
+ if not context_snapshot:
+ context_snapshot = _flat_pipeline_context_from_a2a_snapshot(snapshot, loaded_pipeline)
+ summary = build_handoff_summary(
+ pipeline_name=pipeline_name,
+ outcome="canceled",
+ context_snapshot=context_snapshot,
+ include_fields=include_fields,
+ )
+ data: dict[str, Any] = {
+ "action": "switch_to_normal",
+ "targetMode": "normal",
+ "outcome": "canceled",
+ "summary": summary,
+ "reason": reason,
+ }
+ cleanup = _pipeline_cleanup_handoff_data_from_session(cwd=cwd, session_id=session_id, public_snapshot=snapshot)
+ if cleanup is not None:
+ data["cleanup"] = cleanup
+ return translator.manual_event(
+ "pipeline_handoff_ready",
+ "pipeline",
+ status="canceled",
+ data=data,
+ )
+
+
+def _load_pipeline_definition_for_handoff(pipeline_name: str) -> Any | None:
+ try:
+ pipeline_dir = discover_pipelines().get(pipeline_name)
+ if pipeline_dir is None:
+ return None
+ return load_pipeline_dir(pipeline_dir)
+ except Exception:
+ logger.warning("Failed to load A2A pipeline handoff policy for %s", pipeline_name, exc_info=True)
+ return None
+
+
+def _flat_pipeline_context_from_sidecar(*, cwd: str, session_id: str) -> dict[str, Any]:
+ try:
+ restored = PipelineSession(SessionStorage().session_dir(cwd, session_id) / "pipeline").restore_sync()
+ except Exception:
+ logger.warning("Failed to load pipeline context for A2A cancel handoff", exc_info=True)
+ return {}
+ if not isinstance(restored, dict):
+ return {}
+ context_snapshot = restored.get("context_snapshot")
+ if not isinstance(context_snapshot, dict):
+ return {}
+ return _flatten_pipeline_context_snapshot(context_snapshot)
+
+
+def _flat_pipeline_context_from_a2a_snapshot(snapshot: dict[str, Any] | None, loaded_pipeline: Any) -> dict[str, Any]:
+ if not isinstance(snapshot, dict):
+ return {}
+ field_by_step_id = {
+ str(getattr(step, "step_id")): str(getattr(step, "conclusion_field"))
+ for step in getattr(loaded_pipeline, "steps", [])
+ if getattr(step, "step_id", None) and getattr(step, "conclusion_field", None)
+ }
+ context: dict[str, Any] = {}
+ for step in snapshot.get("steps", []) if isinstance(snapshot.get("steps"), list) else []:
+ if not isinstance(step, dict):
+ continue
+ field_name = field_by_step_id.get(str(step.get("id") or ""))
+ if not field_name:
+ continue
+ conclusion = step.get("conclusion")
+ if conclusion is not None:
+ context[field_name] = conclusion
+ return context
+
+
+def _flatten_pipeline_context_snapshot(snapshot: dict[str, Any]) -> dict[str, Any]:
+ flattened: dict[str, Any] = {}
+ for field_name, field_value in snapshot.items():
+ if isinstance(field_value, dict) and "value" in field_value:
+ value = field_value.get("value")
+ if value is not None:
+ flattened[field_name] = value
+ return flattened
+
+
def terminal_task_state_from_sidecar(*, cwd: str, session_id: str, context_id: str, task_id: str) -> str | None:
pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id)
journal = A2APipelineJournal(pipeline_dir)
@@ -1501,6 +1769,109 @@ def _persist_normal_handoff_summary(pipeline: Any, summary: str) -> None:
logger.warning("Failed to persist A2A pipeline normal handoff summary", exc_info=True)
+def _pipeline_cleanup_handoff_data(pipeline: Any) -> dict[str, Any] | None:
+ cleanup_ledger = getattr(pipeline, "cleanup_ledger", None)
+ if not callable(cleanup_ledger):
+ return None
+ try:
+ ledger = cleanup_ledger()
+ except Exception:
+ logger.warning("Failed to build A2A pipeline cleanup handoff data", exc_info=True)
+ return None
+ return _pipeline_cleanup_handoff_data_from_ledger(ledger)
+
+
+def _pipeline_cleanup_handoff_data_from_session(
+ *,
+ cwd: str,
+ session_id: str,
+ public_snapshot: dict[str, Any] | None = None,
+) -> dict[str, Any] | None:
+ try:
+ ledger_path = SessionStorage().session_dir(cwd, session_id) / "pipeline" / "cleanup.yaml"
+ except Exception:
+ logger.warning("Failed to locate A2A pipeline cleanup ledger for handoff", exc_info=True)
+ return None
+ if not ledger_path.exists():
+ snapshot_cleanup = public_snapshot.get("cleanup") if isinstance(public_snapshot, dict) else None
+ if _public_cleanup_snapshot_has_pending_evidence(snapshot_cleanup):
+ return _cleanup_state_unavailable_payload()
+ return None
+ return _pipeline_cleanup_handoff_data_from_ledger(CleanupLedger(ledger_path))
+
+
+def _pipeline_cleanup_handoff_data_from_ledger(ledger: Any) -> dict[str, Any] | None:
+ try:
+ ledger_path = getattr(ledger, "path", None)
+ if ledger_path is not None and not Path(ledger_path).exists():
+ return _cleanup_state_unavailable_payload()
+ load_failed = getattr(ledger, "load_failed", None)
+ if callable(load_failed) and load_failed():
+ return _cleanup_state_unavailable_payload()
+ build_pending_prompt = getattr(ledger, "build_pending_prompt", None)
+ if not callable(build_pending_prompt):
+ return None
+ prompt = build_pending_prompt()
+ except Exception:
+ logger.warning("Failed to build A2A pipeline cleanup handoff data", exc_info=True)
+ return _cleanup_state_unavailable_payload()
+ if prompt is None:
+ return None
+
+ resources = list(getattr(prompt, "resources", []) or [])
+ if not resources:
+ return None
+ return {
+ "status": "pending",
+ "resourceCount": len(resources),
+ "statusMessage": str(getattr(prompt, "status_message", "") or ""),
+ "resources": [_cleanup_resource_handoff_data(resource) for resource in resources],
+ }
+
+
+def _cleanup_state_unavailable_payload() -> dict[str, Any]:
+ return {
+ "status": "unavailable",
+ "statusMessage": _("Cleanup state unavailable. Inspect the session file and cloud resources manually."),
+ }
+
+
+def _public_cleanup_snapshot_has_pending_evidence(cleanup: Any) -> bool:
+ if not isinstance(cleanup, dict):
+ return False
+ resources = cleanup.get("resources")
+ if isinstance(resources, list) and len(resources) > 0:
+ return True
+ resource_count = cleanup.get("resourceCount")
+ if isinstance(resource_count, int) and resource_count > 0:
+ return True
+ status = cleanup.get("status")
+ if isinstance(status, str) and status in {"pending", "started", "in_progress", "failed", "unavailable"}:
+ return True
+ return False
+
+
+def _cleanup_resource_handoff_data(resource: Any) -> dict[str, Any]:
+ return {
+ "provider": str(getattr(resource, "provider", "") or ""),
+ "resourceType": str(getattr(resource, "resource_type", "") or ""),
+ "resourceId": str(getattr(resource, "resource_id", "") or ""),
+ "resourceName": str(getattr(resource, "resource_name", "") or ""),
+ "regionId": str(getattr(resource, "region_id", "") or ""),
+ "sourceStepId": str(getattr(resource, "source_step_id", "") or ""),
+ "cleanupStatus": str(getattr(resource, "cleanup_status", "") or ""),
+ "progressStatus": getattr(resource, "progress_status", None),
+ "lastError": _public_cleanup_error(getattr(resource, "last_error", None)),
+ }
+
+
+def _public_cleanup_error(value: Any) -> str | None:
+ if not value:
+ return None
+ text = sanitize_public_text(value)
+ return text[:1000] + "..." if len(text) > 1000 else text
+
+
async def _maybe_await(value: Any) -> Any:
if inspect.isawaitable(value):
return await value
@@ -1727,6 +2098,22 @@ def _sidecar_matches_task(
return False
+def _active_sidecar_mismatch_error_from_publisher(
+ publisher: PipelineA2AEventPublisher,
+ *,
+ context_id: str,
+ sidecar_status: str,
+) -> RecoverablePipelineInvalidParamsError:
+ owner = _current_sidecar_owner(publisher, context_id=context_id)
+ recoverable_task_id = owner.task_id if owner is not None and owner.task_id else "unknown"
+ recoverable_context_id = owner.context_id if owner is not None and owner.context_id else context_id
+ return _active_sidecar_mismatch_error(
+ recoverable_task_id=recoverable_task_id,
+ context_id=recoverable_context_id,
+ sidecar_status=sidecar_status,
+ )
+
+
def _current_sidecar_owner(publisher: PipelineA2AEventPublisher, *, context_id: str) -> _TaskContextOwner | None:
return _current_sidecar_owner_from_stores(
snapshot_store=publisher.snapshot_store,
diff --git a/src/iac_code/a2a/pipeline_journal.py b/src/iac_code/a2a/pipeline_journal.py
index 2a2586bf..201bc915 100644
--- a/src/iac_code/a2a/pipeline_journal.py
+++ b/src/iac_code/a2a/pipeline_journal.py
@@ -8,7 +8,11 @@
from pathlib import Path
from typing import Any
+from iac_code.utils.state_io import fsync_parent_dir
+
logger = logging.getLogger(__name__)
+_EVENT_GROUP_RECORD_TYPE = "event_group"
+_EVENT_GROUP_RECORD_KEY = "__iac_code_record_type"
class A2APipelineJournalReadError(ValueError):
@@ -20,8 +24,9 @@ def __init__(self, pipeline_dir: str | Path) -> None:
self.pipeline_dir = Path(pipeline_dir)
self.path = self.pipeline_dir / "a2a-events.jsonl"
- def append(self, event: dict[str, Any]) -> None:
+ def append(self, event: dict[str, Any], durable: bool = False) -> None:
self.pipeline_dir.mkdir(parents=True, exist_ok=True)
+ created = not self.path.exists()
safe_event = to_json_safe(event)
try:
line = json.dumps(safe_event, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
@@ -31,6 +36,37 @@ def append(self, event: dict[str, Any]) -> None:
with self.path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
handle.flush()
+ if durable:
+ os.fsync(handle.fileno())
+ if durable and created:
+ fsync_parent_dir(self.path)
+
+ def append_many(self, events: list[dict[str, Any]], durable: bool = False) -> None:
+ if not events:
+ return
+
+ self.pipeline_dir.mkdir(parents=True, exist_ok=True)
+ created = not self.path.exists()
+ safe_events = []
+ for event in events:
+ safe_event = to_json_safe(event)
+ if not isinstance(safe_event, dict):
+ raise TypeError("A2A journal group events must be JSON objects")
+ safe_events.append(safe_event)
+ record = {
+ _EVENT_GROUP_RECORD_KEY: _EVENT_GROUP_RECORD_TYPE,
+ "schemaVersion": "1.0",
+ "groupId": uuid.uuid4().hex,
+ "events": safe_events,
+ }
+ line = json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
+ with self.path.open("a", encoding="utf-8") as handle:
+ handle.write(line + "\n")
+ handle.flush()
+ if durable:
+ os.fsync(handle.fileno())
+ if durable and created:
+ fsync_parent_dir(self.path)
def read_all(self) -> list[dict[str, Any]]:
return self._read_all(strict=False)
@@ -116,7 +152,7 @@ def _read_all(self, *, strict: bool) -> list[dict[str, Any]]:
f"Non-object A2A pipeline journal line {line_number} in {self.path}"
)
continue
- events.append(value)
+ events.extend(_events_from_journal_record(value, strict=strict, line_number=line_number, path=self.path))
events.sort(key=_sequence_value)
return events
@@ -133,6 +169,25 @@ def _sequence_value(event: dict[str, Any]) -> int:
return 0
+def _events_from_journal_record(
+ value: dict[str, Any],
+ *,
+ strict: bool,
+ line_number: int,
+ path: Path,
+) -> list[dict[str, Any]]:
+ if value.get(_EVENT_GROUP_RECORD_KEY) != _EVENT_GROUP_RECORD_TYPE:
+ return [value]
+
+ group_events = value.get("events")
+ if not isinstance(group_events, list) or not all(isinstance(event, dict) for event in group_events):
+ if strict:
+ raise A2APipelineJournalReadError(f"Invalid A2A pipeline journal event group line {line_number} in {path}")
+ logger.warning("Skipping invalid A2A pipeline journal event group in %s", path)
+ return []
+ return group_events
+
+
def _repairable_tail_bytes(content: bytes) -> tuple[bytes, bytes] | None:
if not content:
return None
diff --git a/src/iac_code/a2a/pipeline_recovery.py b/src/iac_code/a2a/pipeline_recovery.py
index 99c984ba..a9789d2b 100644
--- a/src/iac_code/a2a/pipeline_recovery.py
+++ b/src/iac_code/a2a/pipeline_recovery.py
@@ -12,6 +12,7 @@
A2APipelineSnapshotStore,
reduce_pipeline_events,
sanitize_pipeline_artifact_uris,
+ sanitize_pipeline_cleanup_private_fields,
)
from iac_code.i18n import _
@@ -44,9 +45,9 @@ async def get_state(
snapshot_store = A2APipelineSnapshotStore(pipeline_dir)
snapshot = snapshot_store.load()
events = journal.read_all_repairing_tail()
+ context_events = _events_for_task(events, task_id=None, context_id=context_id)
recovery_task_id = task_id
if recovery_task_id is None:
- context_events = _events_for_task(events, task_id=None, context_id=context_id)
snapshot_task_id = None
if isinstance(snapshot, dict) and _snapshot_matches_context(snapshot, context_id=context_id):
snapshot_task_id = snapshot.get("taskId")
@@ -88,21 +89,45 @@ async def get_state(
snapshot_store.save(snapshot)
snapshot = snapshot_store.load() or snapshot
elif recovery_task_id is not None and (
- not _snapshot_matches(
+ not _snapshot_matches_or_delivery_alias(
snapshot,
task_id=recovery_task_id,
context_id=context_id,
+ context_events=context_events,
)
- or not _snapshot_seen_events_are_within_context_task(
- snapshot,
- _events_for_task(events, task_id=None, context_id=context_id),
- task_id=recovery_task_id,
+ or (
+ _snapshot_matches(snapshot, task_id=recovery_task_id, context_id=context_id)
+ and not _snapshot_seen_events_are_within_context_task(
+ snapshot,
+ context_events,
+ task_id=recovery_task_id,
+ )
+ )
+ or (
+ not _snapshot_matches(snapshot, task_id=recovery_task_id, context_id=context_id)
+ and _snapshot_is_missing_delivery_alias_events(
+ snapshot,
+ task_id=recovery_task_id,
+ context_events=context_events,
+ )
)
):
- if not replay_events:
+ rebuild_events = _rebuild_events_for_recovery_task(
+ events,
+ snapshot=snapshot,
+ task_id=recovery_task_id,
+ context_id=context_id,
+ fallback_events=replay_events,
+ )
+ if not rebuild_events:
raise ValueError(_("A2A pipeline state not found"))
- snapshot = reduce_pipeline_events(replay_events)
- if not _snapshot_matches(snapshot, task_id=recovery_task_id, context_id=context_id):
+ snapshot = reduce_pipeline_events(rebuild_events)
+ if not _snapshot_matches_or_delivery_alias(
+ snapshot,
+ task_id=recovery_task_id,
+ context_id=context_id,
+ context_events=context_events,
+ ):
raise ValueError(_("A2A pipeline state not found"))
if task_id is None:
snapshot_store.save(snapshot)
@@ -132,7 +157,13 @@ async def get_state(
snapshot = snapshot_store.load() or snapshot
if task_id is not None and not _snapshot_matches(snapshot, task_id=task_id, context_id=context_id):
- raise ValueError(_("A2A pipeline state not found"))
+ if not _snapshot_matches_or_delivery_alias(
+ snapshot,
+ task_id=task_id,
+ context_id=context_id,
+ context_events=context_events,
+ ):
+ raise ValueError(_("A2A pipeline state not found"))
if (
task_id is None
and recovery_task_id is not None
@@ -149,8 +180,8 @@ async def get_state(
replay_after = after_sequence if after_sequence is not None else _int_value(snapshot.get("lastSequence"), 0)
events_after_replay = [event for event in replay_events if _int_value(event.get("sequence"), 0) > replay_after]
return {
- "snapshot": _json_compatible(sanitize_pipeline_artifact_uris(snapshot)),
- "events": _json_compatible(sanitize_pipeline_artifact_uris(events_after_replay)),
+ "snapshot": _json_compatible(_sanitize_public_recovery_payload(snapshot)),
+ "events": _json_compatible(_sanitize_public_recovery_payload(events_after_replay)),
}
async def _verify_task_owner(
@@ -182,6 +213,10 @@ def _json_compatible(value: Any) -> Any:
return value
+def _sanitize_public_recovery_payload(value: Any) -> Any:
+ return sanitize_pipeline_cleanup_private_fields(sanitize_pipeline_artifact_uris(value))
+
+
def _events_for_task(
events: list[dict[str, Any]],
*,
@@ -191,13 +226,77 @@ def _events_for_task(
context_events = [event for event in events if event.get("contextId") == context_id]
if task_id is None:
return context_events
- return [event for event in context_events if event.get("taskId") == task_id]
+ return [
+ event for event in context_events if event.get("taskId") == task_id or event.get("deliveryTaskId") == task_id
+ ]
def _snapshot_matches(snapshot: dict[str, Any], *, task_id: str, context_id: str) -> bool:
return snapshot.get("taskId") == task_id and snapshot.get("contextId") == context_id
+def _snapshot_matches_or_delivery_alias(
+ snapshot: dict[str, Any],
+ *,
+ task_id: str,
+ context_id: str,
+ context_events: list[dict[str, Any]],
+) -> bool:
+ if _snapshot_matches(snapshot, task_id=task_id, context_id=context_id):
+ return True
+ if not _snapshot_matches_context(snapshot, context_id=context_id):
+ return False
+ snapshot_task_id = snapshot.get("taskId")
+ if not isinstance(snapshot_task_id, str):
+ return False
+ return any(
+ event.get("taskId") == snapshot_task_id and event.get("deliveryTaskId") == task_id for event in context_events
+ )
+
+
+def _snapshot_is_missing_delivery_alias_events(
+ snapshot: dict[str, Any],
+ *,
+ task_id: str,
+ context_events: list[dict[str, Any]],
+) -> bool:
+ snapshot_task_id = snapshot.get("taskId")
+ if not isinstance(snapshot_task_id, str):
+ return False
+ alias_events = [
+ event
+ for event in context_events
+ if event.get("taskId") == snapshot_task_id and event.get("deliveryTaskId") == task_id
+ ]
+ if not alias_events:
+ return False
+ seen_event_ids = snapshot.get("seenEventIds")
+ if isinstance(seen_event_ids, list):
+ seen = {event_id for event_id in seen_event_ids if isinstance(event_id, str)}
+ return any(isinstance(event.get("eventId"), str) and event["eventId"] not in seen for event in alias_events)
+ snapshot_sequence = _int_value(snapshot.get("lastSequence"), 0)
+ return any(_int_value(event.get("sequence"), 0) > snapshot_sequence for event in alias_events)
+
+
+def _rebuild_events_for_recovery_task(
+ events: list[dict[str, Any]],
+ *,
+ snapshot: dict[str, Any],
+ task_id: str,
+ context_id: str,
+ fallback_events: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ if _snapshot_matches(snapshot, task_id=task_id, context_id=context_id):
+ return fallback_events
+ snapshot_task_id = snapshot.get("taskId")
+ if not isinstance(snapshot_task_id, str):
+ return fallback_events
+ source_events = _events_for_task(events, task_id=snapshot_task_id, context_id=context_id)
+ if any(event.get("deliveryTaskId") == task_id for event in source_events):
+ return source_events
+ return fallback_events
+
+
def _snapshot_matches_context(snapshot: dict[str, Any], *, context_id: str) -> bool:
return snapshot.get("contextId") == context_id
@@ -270,7 +369,15 @@ def _snapshot_seen_events_are_within_context_task(
event_task_ids = {
event.get("eventId"): event.get("taskId") for event in context_events if isinstance(event.get("eventId"), str)
}
+ event_delivery_task_ids = {
+ event.get("eventId"): event.get("deliveryTaskId")
+ for event in context_events
+ if isinstance(event.get("eventId"), str)
+ }
return all(
- not isinstance(event_id, str) or event_id not in event_task_ids or event_task_ids[event_id] == task_id
+ not isinstance(event_id, str)
+ or event_id not in event_task_ids
+ or event_task_ids[event_id] == task_id
+ or event_delivery_task_ids.get(event_id) == task_id
for event_id in seen_event_ids
)
diff --git a/src/iac_code/a2a/pipeline_snapshot.py b/src/iac_code/a2a/pipeline_snapshot.py
index edef7ad6..6d54b69e 100644
--- a/src/iac_code/a2a/pipeline_snapshot.py
+++ b/src/iac_code/a2a/pipeline_snapshot.py
@@ -3,8 +3,6 @@
import copy
import json
import logging
-import os
-import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -14,15 +12,41 @@
sanitize_public_tool_output_data,
)
from iac_code.a2a.pipeline_journal import to_json_safe
+from iac_code.pipeline.constants import (
+ PIPELINE_EVENT_CLEANUP_COMPLETED,
+ PIPELINE_EVENT_CLEANUP_FAILED,
+ PIPELINE_EVENT_CLEANUP_PROGRESS,
+ PIPELINE_EVENT_CLEANUP_STARTED,
+)
+from iac_code.utils.public_errors import sanitize_public_text
+from iac_code.utils.state_io import atomic_write_json
SNAPSHOT_SCHEMA_VERSION = "1.1"
logger = logging.getLogger(__name__)
+_PUBLIC_TEXT_MAX_CHARS = 1000
_TERMINAL_STATUS_BY_EVENT_TYPE = {
"pipeline_completed": "completed",
"pipeline_failed": "failed",
"pipeline_canceled": "canceled",
}
+_CLEANUP_STATUS_BY_EVENT_TYPE = {
+ PIPELINE_EVENT_CLEANUP_STARTED: "started",
+ PIPELINE_EVENT_CLEANUP_PROGRESS: "in_progress",
+ PIPELINE_EVENT_CLEANUP_COMPLETED: "completed",
+ PIPELINE_EVENT_CLEANUP_FAILED: "failed",
+}
+_KNOWN_CLEANUP_STATUSES = {"pending", "started", "in_progress", "completed", "failed", "skipped"}
+_CLEANUP_ERROR_KEYS = {
+ "error",
+ "errorMessage",
+ "errorSummary",
+ "error_message",
+ "error_summary",
+ "lastError",
+ "last_error",
+}
+_PIPELINE_WARNING_PRIVATE_DATA_KEYS = {"ledger_path", "ledgerPath", "load_error", "loadError"}
class A2APipelineSnapshotStore:
@@ -32,29 +56,19 @@ def __init__(self, pipeline_dir: str | Path) -> None:
def save(self, snapshot: dict[str, Any]) -> bool:
previous = self.load()
- next_snapshot = copy.deepcopy(snapshot)
+ next_snapshot = _sanitize_public_snapshot_private_cleanup_fields(snapshot)
next_snapshot["snapshotVersion"] = _snapshot_version(previous) + 1
next_snapshot = to_json_safe(next_snapshot)
if not isinstance(next_snapshot, dict):
logger.warning("Skipping invalid A2A pipeline snapshot for %s", self.path)
return False
- self.pipeline_dir.mkdir(parents=True, exist_ok=True)
- tmp_path = self.path.with_name(f"{self.path.name}.{uuid.uuid4().hex}.tmp")
try:
- with tmp_path.open("w", encoding="utf-8") as handle:
- json.dump(next_snapshot, handle, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False)
- handle.write("\n")
- handle.flush()
- os.fsync(handle.fileno())
- tmp_path.replace(self.path)
+ atomic_write_json(self.path, next_snapshot, durable=True)
return True
except (OSError, TypeError, ValueError):
logger.warning("Failed to persist A2A pipeline snapshot to %s", self.path, exc_info=True)
return False
- finally:
- if tmp_path.exists():
- tmp_path.unlink()
def load(self) -> dict[str, Any] | None:
try:
@@ -71,7 +85,7 @@ def load(self) -> dict[str, Any] | None:
type(value).__name__,
)
return None
- return value
+ return _sanitize_public_snapshot_private_cleanup_fields(value)
def reduce_pipeline_events(
@@ -109,6 +123,25 @@ def sanitize_pipeline_artifact_uris(value: Any) -> Any:
return sanitized
+def sanitize_pipeline_cleanup_private_fields(value: Any) -> Any:
+ if isinstance(value, list):
+ return [sanitize_pipeline_cleanup_private_fields(item) for item in value]
+ if not isinstance(value, dict):
+ return value
+
+ sanitized = _sanitize_cleanup_private_fields(value)
+ event_type = _string_or_none(sanitized.get("eventType")) or ""
+ if event_type in _CLEANUP_STATUS_BY_EVENT_TYPE or sanitized.get("scope") == "cleanup":
+ data = _dict_or_none(sanitized.get("data"))
+ if data is not None:
+ sanitized["data"] = _sanitize_cleanup_private_fields(data, root_is_cleanup=True)
+ elif event_type == "pipeline_warning":
+ data = _dict_or_none(sanitized.get("data"))
+ if data is not None:
+ sanitized["data"] = _pipeline_warning_public_data(data)
+ return sanitized
+
+
class _PipelineSnapshotReducer:
def __init__(self, existing_snapshot: dict[str, Any] | None = None) -> None:
self._snapshot = _snapshot_from_existing(existing_snapshot)
@@ -128,7 +161,9 @@ def __init__(self, existing_snapshot: dict[str, Any] | None = None) -> None:
self._rollback_keys: set[str] = set()
self._candidate_restart_keys: set[str] = set()
self._handoff_history_keys: set[str] = set()
+ self._warning_history_keys: set[str] = set()
self._stack_history_keys: set[str] = set()
+ self._cleanup_history_keys: set[str] = set()
self._skip_sequences_through = 0
self._hydrate_existing_snapshot(existing_snapshot)
@@ -178,7 +213,9 @@ def _hydrate_existing_snapshot(self, existing_snapshot: dict[str, Any] | None) -
self._hydrate_rollbacks()
self._hydrate_candidate_restarts()
self._hydrate_control_history("handoffHistory", self._handoff_history_keys)
+ self._hydrate_control_history("warningHistory", self._warning_history_keys)
self._hydrate_stack_history()
+ self._hydrate_cleanup_history()
def _hydrate_steps(self) -> None:
valid_steps: list[dict[str, Any]] = []
@@ -336,6 +373,24 @@ def _hydrate_stack_history(self) -> None:
self._seen_event_ids.add(event_id)
stacks["history"] = unique_history
+ def _hydrate_cleanup_history(self) -> None:
+ cleanup = self._snapshot["cleanup"]
+ unique_history: list[dict[str, Any]] = []
+ for item in cleanup["history"]:
+ if not isinstance(item, dict):
+ continue
+ event_id = _string_or_none(item.get("eventId"))
+ key = event_id or str(_sequence_value(item))
+ if key in self._cleanup_history_keys:
+ if event_id is not None:
+ self._seen_event_ids.add(event_id)
+ continue
+ self._cleanup_history_keys.add(key)
+ unique_history.append(item)
+ if event_id is not None:
+ self._seen_event_ids.add(event_id)
+ cleanup["history"] = unique_history
+
def _is_legacy_replay_event(self, event: dict[str, Any]) -> bool:
return self._skip_sequences_through > 0 and _sequence_value(event) <= self._skip_sequences_through
@@ -357,13 +412,22 @@ def _apply(self, event: dict[str, Any]) -> None:
self._snapshot["lastSequence"] = max(self._snapshot["lastSequence"], _sequence_value(event))
self._merge_pipeline_identity(event)
- data = _dict_or_empty(event.get("data"))
+ data = _sanitize_cleanup_private_fields(_dict_or_empty(event.get("data")))
if event_type == "pipeline_started":
self._apply_pipeline_started(data)
elif event_type == "pipeline_handoff_ready":
handoff = _normal_handoff(event)
self._snapshot["normalHandoff"] = handoff
self._append_control_history("handoffHistory", self._handoff_history_keys, handoff)
+ cleanup_data = _dict_or_none(data.get("cleanup"))
+ if cleanup_data is not None:
+ self._apply_cleanup_data(cleanup_data, event)
+ elif event_type == "pipeline_warning":
+ self._append_control_history(
+ "warningHistory",
+ self._warning_history_keys,
+ _warning_history_entry(event),
+ )
step = self._upsert_step(event.get("step"), event)
candidate = self._upsert_candidate(step, event.get("candidate"), event)
@@ -396,6 +460,8 @@ def _apply(self, event: dict[str, Any]) -> None:
self._upsert_tool_result_item(event)
elif event_type == "stack_current_changed":
self._apply_stack_current_changed(event)
+ elif event_type in _CLEANUP_STATUS_BY_EVENT_TYPE:
+ self._apply_cleanup_event(event)
elif event_type == "rollback_completed":
self._append_rollback(event)
elif event_type == "candidate_restart_requested":
@@ -412,7 +478,7 @@ def _apply(self, event: dict[str, Any]) -> None:
self._snapshot["status"] = terminal_status
self._snapshot["pendingInput"] = None
self._snapshot["control"]["activeCandidateRunIds"] = []
- elif event_type not in {"input_required", "input_received"} and not (
+ elif event_type not in {"input_required", "input_received", *_CLEANUP_STATUS_BY_EVENT_TYPE} and not (
event_type == "pipeline_handoff_ready" and self._snapshot["status"] in {"completed", "failed", "canceled"}
):
self._apply_event_status(event)
@@ -745,6 +811,82 @@ def _apply_stack_current_changed(self, event: dict[str, Any]) -> None:
else:
stacks["current"] = copy.deepcopy(existing)
+ def _apply_cleanup_event(self, event: dict[str, Any]) -> None:
+ data = _sanitize_cleanup_private_fields(copy.deepcopy(_dict_or_empty(event.get("data"))), root_is_cleanup=True)
+ event_type = _string_or_none(event.get("eventType")) or ""
+ data.setdefault("status", _CLEANUP_STATUS_BY_EVENT_TYPE.get(event_type, "pending"))
+ self._apply_cleanup_data(data, event)
+
+ def _apply_cleanup_data(self, data: dict[str, Any], event: dict[str, Any]) -> None:
+ cleanup = self._snapshot["cleanup"]
+ status = _string_or_none(data.get("status"))
+ if status is not None:
+ cleanup["status"] = status
+
+ resources = _dict_list(data.get("resources"))
+ if resources:
+ cleanup["resources"] = copy.deepcopy(resources)
+ else:
+ self._merge_cleanup_resource(cleanup, data)
+
+ resource_count = _int_or_none(data.get("resourceCount"))
+ if resource_count is not None:
+ cleanup["resourceCount"] = resource_count
+ elif cleanup["resources"]:
+ cleanup["resourceCount"] = len(cleanup["resources"])
+ cleanup["status"] = _aggregate_cleanup_status(cleanup["resources"], fallback=status or cleanup.get("status"))
+
+ for key in ("statusMessage",):
+ if key in data:
+ cleanup[key] = copy.deepcopy(data[key])
+
+ key = _string_or_none(event.get("eventId")) or str(_sequence_value(event))
+ if key in self._cleanup_history_keys:
+ return
+ self._cleanup_history_keys.add(key)
+ entry = {
+ "eventType": _string_or_none(event.get("eventType")),
+ "eventId": _string_or_none(event.get("eventId")),
+ "sequence": _sequence_value(event),
+ "createdAt": _string_or_none(event.get("createdAt")),
+ "scope": _string_or_none(event.get("scope")) or "cleanup",
+ "status": cleanup["status"],
+ "data": copy.deepcopy(data),
+ }
+ _merge_event_coordinates(entry, event)
+ cleanup["history"].append(entry)
+
+ @staticmethod
+ def _merge_cleanup_resource(cleanup: dict[str, Any], data: dict[str, Any]) -> None:
+ resource_id = _string_or_none(data.get("resourceId"))
+ if resource_id is None:
+ return
+ provider = _string_or_none(data.get("provider"))
+ resource_type = _string_or_none(data.get("resourceType")) or _string_or_none(data.get("resource_type"))
+ region_id = _string_or_none(data.get("regionId"))
+ resources = cleanup["resources"]
+
+ def optional_field_matches(resource: dict[str, Any], *keys: str, incoming: str | None) -> bool:
+ existing = None
+ for key in keys:
+ existing = _string_or_none(resource.get(key))
+ if existing is not None:
+ break
+ return incoming is None or existing is None or existing == incoming
+
+ for resource in resources:
+ if not isinstance(resource, dict):
+ continue
+ if (
+ resource.get("resourceId") == resource_id
+ and optional_field_matches(resource, "provider", incoming=provider)
+ and optional_field_matches(resource, "resourceType", "resource_type", incoming=resource_type)
+ and optional_field_matches(resource, "regionId", incoming=region_id)
+ ):
+ resource.update(copy.deepcopy(data))
+ return
+ resources.append(copy.deepcopy(data))
+
def _upsert_display_record(
self,
display_key: str,
@@ -832,7 +974,7 @@ def _pending_input(self, event: dict[str, Any]) -> dict[str, Any]:
def _normal_handoff(event: dict[str, Any]) -> dict[str, Any]:
- data = copy.deepcopy(_dict_or_empty(event.get("data")))
+ data = _sanitize_cleanup_private_fields(copy.deepcopy(_dict_or_empty(event.get("data"))))
handoff = {
"eventType": _string_or_none(event.get("eventType")),
"eventId": _string_or_none(event.get("eventId")),
@@ -849,6 +991,23 @@ def _normal_handoff(event: dict[str, Any]) -> dict[str, Any]:
return handoff
+def _warning_history_entry(event: dict[str, Any]) -> dict[str, Any]:
+ entry = {
+ "eventId": _string_or_none(event.get("eventId")),
+ "sequence": _sequence_value(event),
+ "createdAt": _string_or_none(event.get("createdAt")),
+ "data": _pipeline_warning_public_data(_dict_or_empty(event.get("data"))),
+ }
+ _merge_event_coordinates(entry, event)
+ return entry
+
+
+def _pipeline_warning_public_data(data: dict[str, Any]) -> dict[str, Any]:
+ return copy.deepcopy(
+ {key: value for key, value in data.items() if str(key) not in _PIPELINE_WARNING_PRIVATE_DATA_KEYS}
+ )
+
+
def _interaction_history_entry(event: dict[str, Any]) -> dict[str, Any]:
data = copy.deepcopy(_dict_or_empty(event.get("data")))
input_value = copy.deepcopy(_dict_or_empty(event.get("input")))
@@ -924,6 +1083,12 @@ def _empty_snapshot() -> dict[str, Any]:
"byId": {},
"history": [],
},
+ "cleanup": {
+ "status": "none",
+ "resourceCount": 0,
+ "resources": [],
+ "history": [],
+ },
"normalHandoff": None,
"pendingInput": None,
"control": {
@@ -933,11 +1098,38 @@ def _empty_snapshot() -> dict[str, Any]:
"rollbackHistory": [],
"candidateRestarts": [],
"handoffHistory": [],
+ "warningHistory": [],
},
"seenEventIds": [],
}
+def _cleanup_resource_status(resource: dict[str, Any]) -> str | None:
+ status = (
+ _string_or_none(resource.get("cleanupStatus"))
+ or _string_or_none(resource.get("cleanup_status"))
+ or _string_or_none(resource.get("status"))
+ )
+ return status if status in _KNOWN_CLEANUP_STATUSES else None
+
+
+def _aggregate_cleanup_status(resources: list[dict[str, Any]], *, fallback: Any = None) -> str:
+ fallback_status = _string_or_none(fallback) or "none"
+ statuses = [_cleanup_resource_status(resource) for resource in resources if isinstance(resource, dict)]
+ statuses = [status for status in statuses if status is not None]
+ if not statuses:
+ return fallback_status
+ if "failed" in statuses:
+ return "failed"
+ if "in_progress" in statuses:
+ return "in_progress"
+ if "started" in statuses:
+ return "started"
+ if "pending" in statuses:
+ return "pending"
+ return "completed"
+
+
def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(existing_snapshot, dict):
return _empty_snapshot()
@@ -969,9 +1161,27 @@ def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[st
else {},
"history": _dict_list(stacks.get("history")),
}
+ normal_handoff = snapshot.get("normalHandoff")
snapshot["normalHandoff"] = (
- copy.deepcopy(snapshot.get("normalHandoff")) if isinstance(snapshot.get("normalHandoff"), dict) else None
+ _sanitize_cleanup_private_fields(normal_handoff) if isinstance(normal_handoff, dict) else None
)
+ cleanup = snapshot.get("cleanup")
+ if not isinstance(cleanup, dict):
+ cleanup = {}
+ cleanup = _sanitize_cleanup_private_fields(cleanup, root_is_cleanup=True)
+ cleanup_resources = _dict_list(cleanup.get("resources"))
+ cleanup_count = _int_or_none(cleanup.get("resourceCount"))
+ if cleanup_count is None:
+ cleanup_count = len(cleanup_resources)
+ snapshot["cleanup"] = {
+ "status": _string_or_none(cleanup.get("status")) or "none",
+ "resourceCount": cleanup_count,
+ "resources": cleanup_resources,
+ "history": _dict_list(cleanup.get("history")),
+ }
+ for key in ("statusMessage",):
+ if key in cleanup:
+ snapshot["cleanup"][key] = copy.deepcopy(cleanup[key])
control = snapshot.get("control")
if not isinstance(control, dict):
@@ -984,6 +1194,7 @@ def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[st
"rollbackHistory",
"candidateRestarts",
"handoffHistory",
+ "warningHistory",
):
value = snapshot["control"].get(key)
snapshot["control"][key] = copy.deepcopy(value) if isinstance(value, list) else []
@@ -998,6 +1209,71 @@ def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[st
return snapshot
+def _sanitize_public_snapshot_private_cleanup_fields(value: dict[str, Any]) -> dict[str, Any]:
+ sanitized = copy.deepcopy(value)
+ normal_handoff = sanitized.get("normalHandoff")
+ if isinstance(normal_handoff, dict):
+ sanitized["normalHandoff"] = _sanitize_cleanup_private_fields(normal_handoff)
+ cleanup = sanitized.get("cleanup")
+ if isinstance(cleanup, dict):
+ sanitized["cleanup"] = _sanitize_cleanup_private_fields(cleanup, root_is_cleanup=True)
+ control = sanitized.get("control")
+ if isinstance(control, dict):
+ handoff_history = control.get("handoffHistory")
+ if isinstance(handoff_history, list):
+ control["handoffHistory"] = [
+ _sanitize_cleanup_private_fields(item) if isinstance(item, dict) else item for item in handoff_history
+ ]
+ warning_history = control.get("warningHistory")
+ if isinstance(warning_history, list):
+ control["warningHistory"] = [
+ _sanitize_pipeline_warning_history(item) if isinstance(item, dict) else item for item in warning_history
+ ]
+ return sanitized
+
+
+def _sanitize_pipeline_warning_history(item: dict[str, Any]) -> dict[str, Any]:
+ sanitized = copy.deepcopy(item)
+ data = _dict_or_none(sanitized.get("data"))
+ if data is not None:
+ sanitized["data"] = _pipeline_warning_public_data(data)
+ return sanitized
+
+
+def _sanitize_cleanup_private_fields(value: dict[str, Any], *, root_is_cleanup: bool = False) -> dict[str, Any]:
+ sanitized = copy.deepcopy(value)
+ _drop_cleanup_private_fields(sanitized, inside_cleanup=root_is_cleanup)
+ return sanitized
+
+
+def _drop_cleanup_private_fields(value: Any, *, inside_cleanup: bool) -> None:
+ if isinstance(value, dict):
+ if inside_cleanup:
+ for key in ("prompt", "ledgerPath", "ledger_path"):
+ value.pop(key, None)
+ for key in _CLEANUP_ERROR_KEYS & value.keys():
+ value[key] = _sanitize_cleanup_error_value(value[key])
+ for item in value.values():
+ _drop_cleanup_private_fields(item, inside_cleanup=inside_cleanup)
+ cleanup = value.get("cleanup")
+ if cleanup is not None:
+ _drop_cleanup_private_fields(cleanup, inside_cleanup=True)
+ elif isinstance(value, list):
+ for item in value:
+ _drop_cleanup_private_fields(item, inside_cleanup=inside_cleanup)
+
+
+def _sanitize_cleanup_error_value(value: Any) -> Any:
+ if isinstance(value, str):
+ text = sanitize_public_text(value)
+ return text[:_PUBLIC_TEXT_MAX_CHARS] + "..." if len(text) > _PUBLIC_TEXT_MAX_CHARS else text
+ if isinstance(value, dict):
+ return {key: _sanitize_cleanup_error_value(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [_sanitize_cleanup_error_value(item) for item in value]
+ return value
+
+
def _merge_coordinate(target: dict[str, Any], coordinate: dict[str, Any]) -> None:
for key, value in coordinate.items():
if value is not None:
@@ -1212,4 +1488,9 @@ def _utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
-__all__ = ["A2APipelineSnapshotStore", "SNAPSHOT_SCHEMA_VERSION", "reduce_pipeline_events"]
+__all__ = [
+ "A2APipelineSnapshotStore",
+ "SNAPSHOT_SCHEMA_VERSION",
+ "reduce_pipeline_events",
+ "sanitize_pipeline_cleanup_private_fields",
+]
diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py
index 0a1d5356..8ad950fd 100644
--- a/src/iac_code/a2a/pipeline_stream.py
+++ b/src/iac_code/a2a/pipeline_stream.py
@@ -15,10 +15,53 @@
from iac_code.a2a.pipeline_events import PipelineEventTranslator, safe_permission_metadata
from iac_code.a2a.pipeline_journal import A2APipelineJournal, to_json_safe
from iac_code.a2a.pipeline_snapshot import SNAPSHOT_SCHEMA_VERSION, A2APipelineSnapshotStore, reduce_pipeline_events
+from iac_code.pipeline.constants import (
+ PIPELINE_EVENT_CLEANUP_COMPLETED,
+ PIPELINE_EVENT_CLEANUP_FAILED,
+ PIPELINE_EVENT_CLEANUP_PROGRESS,
+ PIPELINE_EVENT_CLEANUP_STARTED,
+)
from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent, ToolResultEvent
PipelinePermissionResolver = Callable[[PermissionRequestEvent], bool | Awaitable[bool]]
logger = logging.getLogger(__name__)
+_RECOVERY_SEMANTIC_EVENT_TYPES = {
+ "pipeline_started",
+ "pipeline_resumed",
+ "step_started",
+ "step_completed",
+ "step_failed",
+ "candidate_started",
+ "candidate_selected",
+ "candidate_completed",
+ "candidate_failed",
+ "candidate_step_started",
+ "candidate_step_completed",
+ "candidate_step_failed",
+ "input_required",
+ "input_received",
+ "pipeline_completed",
+ "pipeline_failed",
+ "pipeline_canceled",
+ "pipeline_handoff_ready",
+ "pipeline_warning",
+ PIPELINE_EVENT_CLEANUP_STARTED,
+ PIPELINE_EVENT_CLEANUP_PROGRESS,
+ PIPELINE_EVENT_CLEANUP_COMPLETED,
+ PIPELINE_EVENT_CLEANUP_FAILED,
+ "artifact_created",
+ "rollback_completed",
+ "candidate_restart_requested",
+}
+_DISPLAY_ONLY_EVENT_TYPES = {
+ "candidate_detail_shown",
+ "diagram_shown",
+ "permission_requested",
+ "text_delta",
+ "tool_result",
+}
+_RECOVERY_STATE_SCOPES = {"step", "candidate", "candidateStep", "candidate_step"}
+_RECOVERY_STATE_STATUSES = {"working"}
class _SnapshotCatchUpUnavailableError(Exception):
@@ -38,6 +81,8 @@ def __init__(
snapshot_store: A2APipelineSnapshotStore,
artifact_store: Any | None = None,
exposure_types: Any = None,
+ delivery_task_id: str | None = None,
+ delivery_context_id: str | None = None,
) -> None:
self.event_queue = event_queue
self.translator = translator
@@ -45,6 +90,8 @@ def __init__(
self.snapshot_store = snapshot_store
self.artifact_store = artifact_store
self.exposure_types = normalize_a2a_exposure_types(exposure_types)
+ self.delivery_task_id = delivery_task_id
+ self.delivery_context_id = delivery_context_id
self._sequence_lock = asyncio.Lock()
self._last_sequence = 0
self.last_envelope: dict[str, Any] | None = None
@@ -196,6 +243,7 @@ async def publish_manual(
status: str = "working",
data: dict[str, Any] | None = None,
coordinates: dict[str, Any] | None = None,
+ require_durable_metadata: bool = False,
) -> dict[str, Any] | None:
envelope = self.translator.manual_event(event_type, scope, status=status, data=data)
if coordinates:
@@ -203,7 +251,11 @@ async def publish_manual(
value = coordinates.get(key)
if isinstance(value, dict):
envelope[key] = dict(value)
- return envelope if await self._persist_and_enqueue(envelope) else None
+ return (
+ envelope
+ if await self._persist_and_enqueue(envelope, require_durable_metadata=require_durable_metadata)
+ else None
+ )
def _next_snapshot(self, envelope: dict[str, Any]) -> dict[str, Any]:
existing_snapshot = self.snapshot_store.load()
@@ -244,6 +296,7 @@ async def _persist_and_enqueue(
require_durable_metadata: bool = False,
) -> bool:
async with self._sequence_lock:
+ self._annotate_delivery_alias(envelope)
try:
self._ensure_monotonic_sequence(envelope)
except _SequenceHighWaterUnavailableError:
@@ -253,10 +306,11 @@ async def _persist_and_enqueue(
if not isinstance(safe_envelope, dict):
logger.warning("Skipping invalid A2A pipeline envelope: %r", envelope)
return False
+ durable_required = require_durable_metadata or is_recovery_semantic_event(safe_envelope)
journal_persisted = False
snapshot_persisted = False
try:
- self.journal.append(safe_envelope)
+ self.journal.append(safe_envelope, durable=durable_required)
journal_persisted = True
except Exception:
logger.warning("Failed to append A2A pipeline journal event", exc_info=True)
@@ -269,7 +323,7 @@ async def _persist_and_enqueue(
logger.warning("Failed to persist A2A pipeline snapshot", exc_info=True)
if snapshot_persisted:
_maybe_inject_test_fault("after_a2a_pipeline_snapshot_saved")
- if require_durable_metadata and not (journal_persisted or snapshot_persisted):
+ if durable_required and not (journal_persisted or snapshot_persisted):
logger.warning("Skipping A2A pipeline status update because durable metadata was not persisted")
return False
if artifact_metadata is not None and not (journal_persisted or snapshot_persisted):
@@ -281,6 +335,14 @@ async def _persist_and_enqueue(
self.last_envelope = safe_envelope
return True
+ def _annotate_delivery_alias(self, envelope: dict[str, Any]) -> None:
+ delivery_task_id = self._delivery_task_id(envelope)
+ delivery_context_id = self._delivery_context_id(envelope)
+ if delivery_task_id != str(envelope.get("taskId")):
+ envelope["deliveryTaskId"] = delivery_task_id
+ if delivery_context_id != str(envelope.get("contextId")):
+ envelope["deliveryContextId"] = delivery_context_id
+
def _ensure_monotonic_sequence(self, envelope: dict[str, Any]) -> None:
current = _int_value(envelope.get("sequence"), 0)
previous = self._last_persisted_sequence()
@@ -359,8 +421,8 @@ async def _maybe_externalize_artifact(
async def _enqueue_artifact_update(self, envelope: dict[str, Any], artifact_metadata: dict[str, Any]) -> None:
await self.event_queue.enqueue_event(
_artifact_update_event(
- task_id=str(envelope["taskId"]),
- context_id=str(envelope["contextId"]),
+ task_id=self._delivery_task_id(envelope),
+ context_id=self._delivery_context_id(envelope),
metadata=artifact_metadata,
)
)
@@ -391,17 +453,25 @@ async def _apply_permission_metadata(
return approved
async def _enqueue_status(self, envelope: dict[str, Any]) -> None:
+ task_id = self._delivery_task_id(envelope)
+ context_id = self._delivery_context_id(envelope)
update = TaskStatusUpdateEvent(
- task_id=str(envelope["taskId"]),
- context_id=str(envelope["contextId"]),
+ task_id=task_id,
+ context_id=context_id,
status=TaskStatus(
state=_a2a_task_state_name(envelope),
- message=_message_for_envelope(envelope),
+ message=_message_for_envelope(envelope, task_id=task_id, context_id=context_id),
),
)
ParseDict({"iac_code": {"pipeline": envelope}}, update.metadata)
await self.event_queue.enqueue_event(update)
+ def _delivery_task_id(self, envelope: dict[str, Any]) -> str:
+ return self.delivery_task_id or str(envelope["taskId"])
+
+ def _delivery_context_id(self, envelope: dict[str, Any]) -> str:
+ return self.delivery_context_id or str(envelope["contextId"])
+
def _permission_request_from(event: Any) -> PermissionRequestEvent | None:
inner = event.inner if isinstance(event, SubPipelineStreamEvent) else event
@@ -418,6 +488,22 @@ def _resolve_permission_future(request: PermissionRequestEvent, approved: bool)
request.response_future.set_result(approved)
+def is_recovery_semantic_event(envelope: dict[str, Any]) -> bool:
+ event_type = envelope.get("eventType")
+ event_type = event_type if isinstance(event_type, str) else None
+ if event_type in _DISPLAY_ONLY_EVENT_TYPES:
+ return False
+ if event_type in _RECOVERY_SEMANTIC_EVENT_TYPES:
+ return True
+ status = envelope.get("status")
+ status = status if isinstance(status, str) else None
+ if status in {"waiting_input", "input_required", "completed", "failed", "canceled"}:
+ return True
+ scope = envelope.get("scope")
+ scope = scope if isinstance(scope, str) else None
+ return scope in _RECOVERY_STATE_SCOPES and status in _RECOVERY_STATE_STATUSES
+
+
def _should_skip_envelope(envelope: dict[str, Any]) -> bool:
return envelope.get("eventType") == "text_delta" and _text_from_envelope(envelope) == ""
@@ -433,14 +519,20 @@ def _maybe_inject_test_fault(point: str) -> None:
os._exit(97)
-def _message_for_envelope(envelope: dict[str, Any]) -> Message | None:
+def _message_for_envelope(
+ envelope: dict[str, Any],
+ *,
+ task_id: str | None = None,
+ context_id: str | None = None,
+) -> Message | None:
if envelope.get("eventType") != "text_delta":
return None
+ message_task_id = task_id or str(envelope["taskId"])
return Message(
- message_id=f"{envelope['taskId']}-pipeline-{envelope['sequence']}",
- task_id=str(envelope["taskId"]),
- context_id=str(envelope["contextId"]),
+ message_id=f"{message_task_id}-pipeline-{envelope['sequence']}",
+ task_id=message_task_id,
+ context_id=context_id or str(envelope["contextId"]),
role=Role.ROLE_AGENT,
parts=[make_text_part(_text_from_envelope(envelope))],
)
@@ -488,4 +580,4 @@ def _int_value(value: Any, default: int) -> int:
return default
-__all__ = ["PipelineA2AEventPublisher", "PipelinePermissionResolver"]
+__all__ = ["PipelineA2AEventPublisher", "PipelinePermissionResolver", "is_recovery_semantic_event"]
diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py
index 23a993e0..714c8079 100644
--- a/src/iac_code/a2a/transports/dispatcher.py
+++ b/src/iac_code/a2a/transports/dispatcher.py
@@ -49,6 +49,10 @@
from iac_code.a2a.events import make_text_part
from iac_code.a2a.executor import IacCodeA2AExecutor
from iac_code.a2a.exposure import normalize_a2a_exposure_types
+from iac_code.a2a.jsonrpc_passthrough import (
+ install_jsonrpc_error_data_passthrough,
+ install_v03_jsonrpc_error_data_passthrough,
+)
from iac_code.a2a.metrics import NoOpA2AMetrics
from iac_code.a2a.persistence import A2APersistenceStore
from iac_code.a2a.pipeline_executor import (
@@ -237,11 +241,13 @@ async def on_list_tasks(self, params: ListTasksRequest, context):
async def on_message_send(self, params: SendMessageRequest, context):
self._validate_extensions(context)
+ self._validate_pipeline_message_request(params)
await self._hydrate_recoverable_pipeline_task_id(params)
return await super().on_message_send(params, context)
async def on_message_send_stream(self, params: SendMessageRequest, context):
self._validate_extensions(context)
+ self._validate_pipeline_message_request(params)
await self._hydrate_recoverable_pipeline_task_id(params)
task_id = params.message.task_id or None
if task_id and isinstance(self.task_store, A2ATaskStore) and await self.task_store.is_task_active(task_id):
@@ -495,6 +501,13 @@ async def on_delete_task_push_notification_config(
self._validate_extensions(context)
await super().on_delete_task_push_notification_config(params, context)
+ def _validate_pipeline_message_request(self, params: SendMessageRequest) -> None:
+ if get_run_mode() != RunMode.PIPELINE:
+ return
+ executor = getattr(self, "agent_executor", None)
+ if isinstance(executor, IacCodeA2AExecutor):
+ executor.validate_pipeline_message_request(params.message)
+
def _validate_extensions(self, context) -> None:
requested = set(getattr(context, "requested_extensions", set()) or set())
required = sorted(extension.uri for extension in self._agent_card.capabilities.extensions if extension.required)
@@ -511,7 +524,9 @@ def _task_is_input_required(task: Task) -> bool:
def _create_dispatch_app(handler: DefaultRequestHandler) -> Starlette:
+ install_jsonrpc_error_data_passthrough()
jsonrpc_endpoint = create_jsonrpc_routes(handler, rpc_url="/", enable_v0_3_compat=True)[0].endpoint
+ install_v03_jsonrpc_error_data_passthrough(jsonrpc_endpoint)
async def handle_jsonrpc(request):
await normalize_v03_jsonrpc_version(request)
diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py
index 9550dbe6..89b73ccd 100644
--- a/src/iac_code/agent/agent_loop.py
+++ b/src/iac_code/agent/agent_loop.py
@@ -9,7 +9,7 @@
from collections import deque
from collections.abc import AsyncGenerator, Callable
from contextlib import suppress
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from typing import Any, Literal
from loguru import logger
@@ -73,6 +73,26 @@ def _normalize_memory_filename(filename: Any) -> str:
return name
+def _extend_unique(target: list[str], values: list[str]) -> None:
+ seen = set(target)
+ for value in values:
+ if value not in seen:
+ target.append(value)
+ seen.add(value)
+
+
+def _with_trusted_read_directories(permission_context: Any, directories: list[str]) -> Any:
+ if not directories:
+ return permission_context
+
+ trusted_read_directories = list(getattr(permission_context, "trusted_read_directories", []))
+ original_count = len(trusted_read_directories)
+ _extend_unique(trusted_read_directories, directories)
+ if len(trusted_read_directories) == original_count:
+ return permission_context
+ return replace(permission_context, trusted_read_directories=trusted_read_directories)
+
+
def _filter_recalled_memory_content(content: str, selected_files: list[str]) -> str:
keep = [_normalize_memory_filename(filename) for filename in selected_files]
keep = [filename for filename in keep if filename]
@@ -129,6 +149,9 @@ def __init__(
memory_recall_service: Any = None,
system_prompt_refresher: Callable[[], str] | None = None,
pause_event: asyncio.Event | None = None,
+ tool_context_trusted_read_directories: list[str] | None = None,
+ tool_context_relative_read_directories: list[str] | None = None,
+ pipeline_mode: bool = False,
) -> None:
self._provider_manager = provider_manager
self.system_prompt = system_prompt
@@ -141,6 +164,9 @@ def __init__(
self._session_usage_totals = self._session_usage_store.load(self._cwd, self._session_id)
self._permission_context = permission_context
self._permission_context_getter = permission_context_getter
+ self._tool_context_trusted_read_directories = list(tool_context_trusted_read_directories or [])
+ self._tool_context_relative_read_directories = list(tool_context_relative_read_directories or [])
+ self._pipeline_mode = pipeline_mode
self._auto_trigger_skills = auto_trigger_skills or []
self._auto_loaded_skills: set[str] = set()
self._current_git_branch: str | None = None
@@ -167,7 +193,7 @@ def __init__(
self._result_storage = ResultStorage(
storage_dir=os.path.join(str(get_config_dir()), "tool-results", self._session_id),
)
- self._pending_injections: deque[str] = deque()
+ self._pending_injections: deque[str | list[ContentBlock]] = deque()
self._current_turn_text: str = ""
self._accepting_injected_user_messages = False
self._pause_event = pause_event
@@ -176,7 +202,7 @@ def __init__(
def current_turn_text(self) -> str:
return self._current_turn_text
- def inject_user_message(self, msg: str) -> None:
+ def inject_user_message(self, msg: str | list[ContentBlock]) -> None:
"""Schedule a user message to be injected before the next LLM turn."""
self._pending_injections.append(msg)
@@ -185,13 +211,27 @@ def can_accept_injected_user_message(self) -> bool:
"""Whether a queued supplement can still be consumed by this run."""
return self._accepting_injected_user_messages
- def try_inject_user_message(self, msg: str) -> bool:
+ def try_inject_user_message(self, msg: str | list[ContentBlock]) -> bool:
"""Queue a supplement only when this loop still has a consumable turn."""
if not self.can_accept_injected_user_message:
return False
self.inject_user_message(msg)
return True
+ def _drain_pending_injections(self) -> None:
+ while self._pending_injections:
+ injected = self._pending_injections.popleft()
+ self.context_manager.add_user_message(injected)
+ if self._session_storage:
+ from iac_code.agent.message import Message
+
+ self._session_storage.append(
+ self._cwd,
+ self._session_id,
+ Message(role="user", content=injected),
+ git_branch=self._current_git_branch,
+ )
+
def set_provider(self, provider_manager: Any, system_prompt: str | None = None) -> None:
"""Swap the provider manager in place, preserving conversation history.
@@ -347,6 +387,7 @@ def _persist_context_messages(self) -> None:
self._session_id,
self.context_manager.get_messages(),
git_branch=self._current_git_branch,
+ preserve_cleanup_prompts=True,
)
def _inject_recalled_memory_result(self, result: Any) -> bool:
@@ -765,8 +806,7 @@ async def _run_streaming_inner(
# inject supplemental user text before the next provider call.
if self._pause_event is not None:
await self._pause_event.wait()
- while self._pending_injections:
- self.context_manager.add_user_message(self._pending_injections.popleft())
+ self._drain_pending_injections()
self._accepting_injected_user_messages = False
self._current_turn_text = ""
@@ -900,7 +940,12 @@ async def _run_streaming_inner(
event_queue=queue,
)
)
- context = ToolContext(cwd=self._cwd)
+ context = ToolContext(
+ cwd=self._cwd,
+ trusted_read_directories=list(self._tool_context_trusted_read_directories),
+ relative_read_directories=list(self._tool_context_relative_read_directories),
+ pipeline_mode=self._pipeline_mode,
+ )
allowed_requests: list[ToolCallRequest] = []
denied_results: list[tuple[ToolCallRequest, ToolResult]] = []
@@ -919,7 +964,14 @@ async def _run_streaming_inner(
if perm_ctx is not None:
from iac_code.services.permissions.pipeline import check_tool_permission
- permission = await check_tool_permission(tool, request.input, perm_ctx)
+ effective_perm_ctx = _with_trusted_read_directories(
+ perm_ctx, self._tool_context_trusted_read_directories
+ )
+ _extend_unique(context.additional_directories, list(effective_perm_ctx.additional_directories))
+ _extend_unique(
+ context.trusted_read_directories, list(effective_perm_ctx.trusted_read_directories)
+ )
+ permission = await check_tool_permission(tool, request.input, effective_perm_ctx)
else:
permission = await tool.check_permissions(request.input, {"cwd": context.cwd})
@@ -1271,6 +1323,7 @@ def stamp_last_turn_elapsed(self, elapsed: float) -> None:
self._session_id,
msgs,
git_branch=self._current_git_branch,
+ preserve_cleanup_prompts=True,
)
break
diff --git a/src/iac_code/agent/message.py b/src/iac_code/agent/message.py
index 08547cc3..7a2b0eea 100644
--- a/src/iac_code/agent/message.py
+++ b/src/iac_code/agent/message.py
@@ -48,6 +48,7 @@ class ImageBlock(BaseModel):
type: Literal["image"] = "image"
media_type: str # 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'
data: str # base64
+ ref_id: int | None = None
# Union type for all content blocks
diff --git a/src/iac_code/commands/prompt.py b/src/iac_code/commands/prompt.py
index 9089dbcd..24ab7f1c 100644
--- a/src/iac_code/commands/prompt.py
+++ b/src/iac_code/commands/prompt.py
@@ -14,6 +14,7 @@
from typing import Any, cast
from iac_code.agent.message import RECALLED_MEMORY_MARKER
+from iac_code.agent.message import Message as AgentMessage
from iac_code.agent.system_prompt import DYNAMIC_BOUNDARY
from iac_code.i18n import _
from iac_code.utils.file_security import ensure_private_file
@@ -83,6 +84,14 @@ def build_prompt_snapshot(repl: object) -> dict[str, Any]:
if "provider_messages" in last_request
else _provider_messages(agent_loop)
)
+ cleanup_messages = _cleanup_prompt_messages(repl, agent_loop)
+ provider_messages = _with_cleanup_prompt_messages(
+ repl,
+ agent_loop,
+ provider_messages,
+ cleanup_messages=cleanup_messages,
+ )
+ cleanup_prompts = _cleanup_prompt_snapshots(cleanup_messages)
tools = list(last_request.get("tools") or []) if "tools" in last_request else _tool_definitions(agent_loop)
status = _status_snapshot(repl)
metadata = {
@@ -98,12 +107,22 @@ def build_prompt_snapshot(repl: object) -> dict[str, Any]:
"system_prompt": system_prompt,
"system_sections": _split_system_prompt(system_prompt),
"provider_messages": provider_messages,
+ "cleanup_prompts": cleanup_prompts,
"tools": tools,
"memory_sections": _memory_sections(repl),
}
def _pipeline_prompt_snapshot(repl: object) -> dict[str, Any] | None:
+ runtime_getter = getattr(repl, "_get_runtime_mode", None)
+ if callable(runtime_getter):
+ try:
+ runtime_mode = runtime_getter()
+ except Exception:
+ runtime_mode = None
+ if str(getattr(runtime_mode, "value", runtime_mode)) != "pipeline":
+ return None
+
pipeline = getattr(repl, "_pipeline", None)
get_prompt_contexts = getattr(pipeline, "get_prompt_contexts", None)
if not callable(get_prompt_contexts):
@@ -220,6 +239,14 @@ def render_prompt_html(snapshot: dict[str, Any]) -> str:
provider_messages = "\n".join(
_message_card(index, message) for index, message in enumerate(snapshot.get("provider_messages", []), start=1)
)
+ cleanup_messages = list(snapshot.get("cleanup_prompts") or [])
+ if not cleanup_messages:
+ cleanup_messages = [
+ message for message in snapshot.get("provider_messages", []) if _is_cleanup_prompt_snapshot(message)
+ ]
+ cleanup_prompts = "\n".join(
+ _message_card(index, message) for index, message in enumerate(cleanup_messages, start=1)
+ )
tools = "\n".join(_tool_card(tool) for tool in snapshot.get("tools", []))
raw_system_prompt = _content_card(
_("Raw Full System Prompt"),
@@ -232,7 +259,10 @@ def render_prompt_html(snapshot: dict[str, Any]) -> str:
raw_system_prompt=raw_system_prompt,
)
messages_tab = provider_messages or '{}
'.format(escape(_("No provider messages yet.")))
+ cleanup_tab = cleanup_prompts or '{}
'.format(escape(_("No cleanup prompts in this snapshot.")))
tools_tab = tools or '{}
'.format(escape(_("No tools are currently registered.")))
+ cleanup_tab_button = _tab_button("cleanup", _("Cleanup Prompts")) if cleanup_messages else ""
+ cleanup_panel = _tab_panel("cleanup", cleanup_tab) if cleanup_messages else ""
return """
@@ -431,11 +461,13 @@ def render_prompt_html(snapshot: dict[str, Any]) -> str:
{all_tab_button}
{system_tab_button}
{messages_tab_button}
+ {cleanup_tab_button}
{tools_tab_button}
{all_panel}
{system_panel}
{messages_panel}
+ {cleanup_panel}
{tools_panel}
")]
+
+ assert "function isTerminalPipelineTaskState" in script
+ assert "isTerminalPipelineTaskState(state.status)" in script
+
+ def extract_function(name: str) -> str:
+ start = script.index(f"function {name}")
+ brace = script.index("{", start)
+ depth = 0
+ for index in range(brace, len(script)):
+ char = script[index]
+ if char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ return script[start : index + 1]
+ raise AssertionError(f"Could not extract {name}")
+
+ functions = [extract_function("streamTaskIdForControls")]
+ if "function isTerminalPipelineTaskState" in script:
+ functions.insert(0, extract_function("isTerminalPipelineTaskState"))
+
+ js_path = tmp_path / "stream-task-routing.js"
+ js_path.write_text(
+ "\n".join(
+ [
+ 'const state = {normalHandoffReady: false, activeTaskId: "", taskId: "task-1", status: ""};',
+ *functions,
+ 'state.status = "TASK_STATE_CANCELED";',
+ 'if (streamTaskIdForControls({activeTaskId: "", taskId: "task-1"}) !== "") {',
+ ' throw new Error("canceled pipeline taskId should not be reused");',
+ "}",
+ 'state.status = "canceled";',
+ 'if (streamTaskIdForControls({activeTaskId: "", taskId: "task-1"}) !== "") {',
+ ' throw new Error("snapshot canceled pipeline taskId should not be reused");',
+ "}",
+ 'state.status = "waiting_input";',
+ 'if (streamTaskIdForControls({activeTaskId: "", taskId: "task-1"}) !== "task-1") {',
+ ' throw new Error("waiting input pipeline taskId should be reused");',
+ "}",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ try:
+ result = subprocess.run(["node", str(js_path)], capture_output=True, text=True, check=False)
+ except FileNotFoundError:
+ pytest.skip("node is not installed")
+
+ assert result.returncode == 0, result.stderr
+
+
def test_index_html_clears_finished_active_task_after_normal_chat_turn() -> None:
debugger = load_debugger_module()
@@ -1190,6 +1342,112 @@ def test_index_html_can_restore_debugger_log_replay_payload(tmp_path: Path) -> N
]
+def test_load_log_dir_replays_state_fetch_cancel_handoff_events(tmp_path: Path) -> None:
+ debugger = load_debugger_module()
+ log_dir = tmp_path / "logs"
+ log_dir.mkdir()
+ (log_dir / "sse-events.jsonl").write_text(
+ json.dumps(
+ {
+ "raw": {
+ "statusUpdate": {
+ "taskId": "task-pipeline",
+ "contextId": "ctx-1",
+ "status": {"state": "TASK_STATE_INPUT_REQUIRED"},
+ "metadata": {
+ "iac_code": {
+ "pipeline": {
+ "eventType": "input_required",
+ "sequence": 72.0,
+ "taskId": "task-pipeline",
+ "contextId": "ctx-1",
+ "status": "input_required",
+ }
+ }
+ },
+ }
+ }
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ (log_dir / "snapshots.jsonl").write_text(
+ json.dumps(
+ {
+ "raw": {
+ "snapshot": {
+ "status": "canceled",
+ "taskId": "task-pipeline",
+ "contextId": "ctx-1",
+ "lastSequence": 74,
+ "normalHandoff": {
+ "action": "switch_to_normal",
+ "targetMode": "normal",
+ "outcome": "canceled",
+ },
+ },
+ "events": [
+ {
+ "eventType": "pipeline_canceled",
+ "sequence": 73,
+ "taskId": "task-pipeline",
+ "contextId": "ctx-1",
+ "status": "canceled",
+ },
+ {
+ "eventType": "pipeline_handoff_ready",
+ "sequence": 74,
+ "taskId": "task-pipeline",
+ "contextId": "ctx-1",
+ "status": "canceled",
+ "data": {
+ "action": "switch_to_normal",
+ "targetMode": "normal",
+ "outcome": "canceled",
+ },
+ },
+ ],
+ }
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ replay = debugger.load_debug_log_export(log_dir)
+
+ assert replay["task"]["taskId"] == "task-pipeline"
+ assert replay["task"]["activeTaskId"] == ""
+ assert replay["task"]["contextId"] == "ctx-1"
+ assert replay["task"]["status"] == "canceled"
+ assert replay["task"]["lastSequence"] == 74
+ assert [event["eventType"] for event in replay["sseEvents"][-2:]] == [
+ "pipeline_canceled",
+ "pipeline_handoff_ready",
+ ]
+
+
+def test_index_html_normal_handoff_summary_reads_snapshot_response_wrapper() -> None:
+ debugger = load_debugger_module()
+
+ html = debugger.render_index_html(
+ debugger.DebuggerConfig(
+ host="127.0.0.1",
+ port=41880,
+ default_server_url="http://127.0.0.1:41299",
+ default_cwd="/workspace/demo",
+ )
+ )
+ normal_handoff_body = html.split("function snapshotNormalHandoff(snapshot)", 1)[1].split(
+ "function normalHandoffSummary(snapshot)",
+ 1,
+ )[0]
+
+ assert "snapshotEnvelope(snapshot)" in normal_handoff_body
+ assert "snapshotObject(envelope &&" in normal_handoff_body
+
+
def test_index_html_fills_context_and_task_id_controls_after_capture() -> None:
debugger = load_debugger_module()
@@ -1254,6 +1512,26 @@ def test_index_html_reads_input_required_data_and_clears_stale_permissions() ->
assert expected in html
+def test_index_html_highlights_pipeline_canceled_events() -> None:
+ debugger = load_debugger_module()
+
+ html = debugger.render_index_html(
+ debugger.DebuggerConfig(
+ host="127.0.0.1",
+ port=41880,
+ default_server_url="http://127.0.0.1:41299",
+ default_cwd="/workspace/demo",
+ )
+ )
+
+ for expected in [
+ 'type === "pipeline_canceled"',
+ 'label: "pipeline canceled"',
+ "timeline-canceled",
+ ]:
+ assert expected in html
+
+
def test_index_html_stops_stream_after_input_required_to_reenable_prompt_submit() -> None:
debugger = load_debugger_module()
@@ -1707,6 +1985,73 @@ def do_POST(self) -> None:
self.end_headers()
+class JsonRpcErrorTargetHandler(BaseHTTPRequestHandler):
+ def log_message(self, format: str, *args: object) -> None:
+ return None
+
+ def do_POST(self) -> None:
+ raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0") or "0"))
+ assert raw_body
+ body = json.dumps(
+ {
+ "jsonrpc": "2.0",
+ "id": "1",
+ "error": {
+ "code": -32602,
+ "message": "Current model text-only-model does not support image input.",
+ "data": {
+ "recoverableTaskId": "task-owner",
+ "contextId": "ctx-1",
+ "sidecarStatus": "running",
+ },
+ },
+ }
+ ).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+
+def test_jsonrpc_error_message_includes_recoverable_task_id() -> None:
+ debugger = load_debugger_module()
+
+ message = debugger._jsonrpc_error_message(RECOVERABLE_JSONRPC_ERROR)
+
+ assert message is not None
+ assert "Pipeline already running." in message
+ assert "task-owner" in message
+
+
+def test_index_html_extracts_delivery_task_aliases() -> None:
+ script = SCRIPT_PATH.read_text(encoding="utf-8")
+
+ assert "statusUpdate.deliveryTaskId" in script
+ assert "statusUpdate.deliveryContextId" in script
+ assert "task.deliveryTaskId" in script
+ assert "envelope.deliveryTaskId" in script
+
+
+def test_jsonrpc_error_message_does_not_duplicate_resume_guidance() -> None:
+ debugger = load_debugger_module()
+ value = {
+ "error": {
+ "code": -32602,
+ "message": "Pipeline already running. Resume task task-owner.",
+ "data": {
+ "recoverableTaskId": "task-owner",
+ "contextId": "ctx-1",
+ "sidecarStatus": "running",
+ },
+ }
+ }
+
+ message = debugger._jsonrpc_error_message(value)
+
+ assert message == "Pipeline already running. Resume task task-owner."
+
+
def test_message_stream_route_forwards_sse_and_uses_stream_payload() -> None:
debugger = load_debugger_module()
SseTargetHandler.requests = []
@@ -1738,6 +2083,81 @@ def test_message_stream_route_forwards_sse_and_uses_stream_payload() -> None:
assert sent["params"]["message"]["metadata"] == {"iac_code": {"cwd": "/workspace/demo"}}
+def test_message_stream_route_forwards_image_parts() -> None:
+ debugger = load_debugger_module()
+ SseTargetHandler.requests = []
+
+ with serve_handler(SseTargetHandler) as target_url:
+ running = start_debugger_server(debugger)
+ try:
+ status, body = post_raw(
+ f"{running.url}/api/message/stream",
+ {
+ "serverUrl": target_url,
+ "cwd": "/workspace/demo",
+ "contextId": "ctx-1",
+ "prompt": "inspect this diagram",
+ "images": [
+ {
+ "filename": "diagram.png",
+ "mediaType": "image/png",
+ "bytes": "iVBORw0KGgo=",
+ }
+ ],
+ },
+ )
+ finally:
+ running.close()
+
+ assert status == 200
+ assert "data: " in body
+ sent = json.loads(SseTargetHandler.requests[0]["body"])
+ assert sent["params"]["message"]["parts"] == [
+ {"text": "inspect this diagram"},
+ {
+ "data": {"filename": "diagram.png", "bytes": "iVBORw0KGgo="},
+ "mediaType": "image/png",
+ },
+ ]
+
+
+def test_message_stream_route_allows_image_only_prompt() -> None:
+ debugger = load_debugger_module()
+ SseTargetHandler.requests = []
+
+ with serve_handler(SseTargetHandler) as target_url:
+ running = start_debugger_server(debugger)
+ try:
+ status, body = post_raw(
+ f"{running.url}/api/message/stream",
+ {
+ "serverUrl": target_url,
+ "cwd": "/workspace/demo",
+ "contextId": "ctx-1",
+ "prompt": "",
+ "images": [
+ {
+ "filename": "diagram.png",
+ "mediaType": "image/png",
+ "bytes": "iVBORw0KGgo=",
+ }
+ ],
+ },
+ )
+ finally:
+ running.close()
+
+ assert status == 200
+ assert "data: " in body
+ sent = json.loads(SseTargetHandler.requests[0]["body"])
+ assert sent["params"]["message"]["parts"] == [
+ {
+ "data": {"filename": "diagram.png", "bytes": "iVBORw0KGgo="},
+ "mediaType": "image/png",
+ },
+ ]
+
+
def test_message_stream_route_writes_sse_debugger_log(tmp_path: Path) -> None:
debugger = load_debugger_module()
SseTargetHandler.requests = []
@@ -1795,6 +2215,35 @@ def test_message_stream_route_logs_empty_upstream_stream(tmp_path: Path) -> None
assert records[-1]["raw"] == {"type": "stream_empty", "statusCode": 200}
+def test_message_stream_route_converts_jsonrpc_error_to_sse_error(tmp_path: Path) -> None:
+ debugger = load_debugger_module()
+
+ with serve_handler(JsonRpcErrorTargetHandler) as target_url:
+ running = start_logged_debugger_server(debugger, log_dir=tmp_path)
+ try:
+ status, body = post_raw(
+ f"{running.url}/api/message/stream",
+ {
+ "serverUrl": target_url,
+ "cwd": "/workspace/demo",
+ "contextId": "ctx-1",
+ "prompt": "inspect image",
+ },
+ )
+ finally:
+ running.close()
+
+ assert status == 200
+ assert "data: " in body
+ assert "Current model text-only-model does not support image input." in body
+ assert "task-owner" in body
+ records = read_jsonl(tmp_path / "sse-events.jsonl")
+ assert records[-1]["parsedEventType"] == "error"
+ assert records[-1]["raw"]["type"] == "error"
+ assert records[-1]["raw"]["body"]["error"]["code"] == -32602
+ assert records[-1]["raw"]["body"]["error"]["data"]["recoverableTaskId"] == "task-owner"
+
+
def test_message_stream_route_ignores_client_disconnect_without_traceback(capsys: pytest.CaptureFixture[str]) -> None:
debugger = load_debugger_module()
diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py
index 81426691..306b64ef 100644
--- a/tests/a2a/test_pipeline_events.py
+++ b/tests/a2a/test_pipeline_events.py
@@ -58,6 +58,61 @@ def test_pipeline_started_has_stable_envelope() -> None:
assert envelope["data"]["totalSteps"] == 4
+def test_pipeline_warning_translates_to_non_terminal_envelope() -> None:
+ translator = PipelineEventTranslator(_ctx())
+
+ [envelope] = translator.translate(
+ PipelineEvent(
+ type=PipelineEventType.PIPELINE_WARNING,
+ step_id="deploying",
+ timestamp=1717821600.0,
+ data={
+ "reason": "cleanup_tracking_unavailable",
+ "operation": "record_observed",
+ "ledger_path": "/Users/alice/.iac-code/projects/demo/cleanup.yaml",
+ "load_error": "while parsing /Users/alice/.iac-code/projects/demo/cleanup.yaml",
+ },
+ )
+ )
+
+ assert envelope["eventType"] == "pipeline_warning"
+ assert envelope["scope"] == "pipeline"
+ assert envelope["status"] == "working"
+ assert envelope["data"]["reason"] == "cleanup_tracking_unavailable"
+ assert "ledger_path" not in envelope["data"]
+ assert "load_error" not in envelope["data"]
+
+
+def test_manual_cleanup_event_normalizes_cleanup_data_keys() -> None:
+ translator = PipelineEventTranslator(_ctx())
+
+ event = translator.manual_event(
+ "cleanup_started",
+ "cleanup",
+ data={
+ "resource_count": 1,
+ "status_message": "检测到 1 个回滚残留资源,开始清理流程。",
+ "resource_id": "stack-123",
+ "region_id": "cn-hangzhou",
+ "stack_status": "DELETE_IN_PROGRESS",
+ "cleanup_tool_use_id": "toolu-get",
+ "progress_percentage": 60,
+ "last_error": "DELETE_FAILED",
+ },
+ )
+
+ assert event["eventType"] == "cleanup_started"
+ assert event["scope"] == "cleanup"
+ assert event["data"]["resourceCount"] == 1
+ assert event["data"]["statusMessage"] == "检测到 1 个回滚残留资源,开始清理流程。"
+ assert event["data"]["resourceId"] == "stack-123"
+ assert event["data"]["regionId"] == "cn-hangzhou"
+ assert event["data"]["stackStatus"] == "DELETE_IN_PROGRESS"
+ assert event["data"]["cleanupToolUseId"] == "toolu-get"
+ assert event["data"]["progressPercentage"] == 60
+ assert event["data"]["lastError"] == "DELETE_FAILED"
+
+
def test_parent_step_attempt_increments_after_rollback() -> None:
translator = PipelineEventTranslator(_ctx())
translator.translate(
@@ -768,6 +823,48 @@ def test_show_candidate_detail_tool_result_recovers_detail_from_tool_input() ->
assert detail_event["data"]["detail"]["costItems"] == [{"name": "ecs", "monthly_cost": "CNY 60"}]
+@pytest.mark.parametrize(
+ ("stream_event", "event_type"),
+ [
+ (TextDeltaEvent(text="开始部署资源"), "text_delta"),
+ (
+ ToolResultEvent(
+ tool_use_id="toolu-read",
+ tool_name="read_file",
+ result="template content",
+ is_error=False,
+ ),
+ "tool_result",
+ ),
+ (
+ PermissionRequestEvent(
+ tool_name="ros_stack",
+ tool_input={"action": "CreateStack"},
+ tool_use_id="toolu-stack",
+ ),
+ "permission_requested",
+ ),
+ ],
+)
+def test_parent_stream_events_include_current_step_coordinate(stream_event: object, event_type: str) -> None:
+ translator = PipelineEventTranslator(_ctx())
+ translator.translate(
+ PipelineEvent(
+ type=PipelineEventType.STEP_STARTED,
+ step_id="deploying",
+ timestamp=time.time(),
+ data={"index": 5, "total": 5},
+ )
+ )
+
+ [envelope] = translator.translate(stream_event)
+
+ assert envelope["eventType"] == event_type
+ assert envelope["scope"] == "step"
+ assert envelope["step"]["id"] == "deploying"
+ assert envelope["step"]["runId"] == "step-deploying-1"
+
+
def test_stack_current_changed_is_disabled_by_default() -> None:
translator = PipelineEventTranslator(_ctx())
translator.translate(
@@ -964,7 +1061,7 @@ def test_stack_current_changed_emits_after_successful_ros_create_stack() -> None
}
-def test_stack_current_changed_clears_current_stack_after_successful_delete() -> None:
+def test_stack_current_changed_keeps_current_stack_after_statusless_successful_delete() -> None:
ctx = _ctx()
ctx.emit_stack_events = True
translator = PipelineEventTranslator(ctx)
@@ -993,6 +1090,48 @@ def test_stack_current_changed_clears_current_stack_after_successful_delete() ->
assert stack_event["eventType"] == "stack_current_changed"
assert stack_event["data"]["action"] == "DeleteStack"
assert stack_event["data"]["stackId"] == "stack-123"
+ assert stack_event["data"]["stackStatus"] == "DELETE_REQUESTED"
+ assert stack_event["data"]["current"] is True
+ assert "cleared" not in stack_event["data"]
+
+
+def test_stack_current_changed_clears_current_stack_after_delete_complete() -> None:
+ ctx = _ctx()
+ ctx.emit_stack_events = True
+ translator = PipelineEventTranslator(ctx)
+ translator.translate(
+ ToolUseEndEvent(
+ tool_use_id="toolu-delete",
+ name="ros_stack",
+ input={
+ "action": "DeleteStack",
+ "region_id": "cn-hangzhou",
+ "params": {"StackId": "stack-123", "StackName": "demo"},
+ },
+ )
+ )
+
+ envelopes = translator.translate(
+ ToolResultEvent(
+ tool_use_id="toolu-delete",
+ tool_name="ros_stack",
+ result=json.dumps(
+ {
+ "stack_id": "stack-123",
+ "stack_name": "demo",
+ "status": "DELETE_COMPLETE",
+ "is_success": True,
+ }
+ ),
+ is_error=False,
+ )
+ )
+
+ stack_event = envelopes[0]
+ assert stack_event["eventType"] == "stack_current_changed"
+ assert stack_event["data"]["action"] == "DeleteStack"
+ assert stack_event["data"]["stackId"] == "stack-123"
+ assert stack_event["data"]["stackStatus"] == "DELETE_COMPLETE"
assert stack_event["data"]["current"] is False
assert stack_event["data"]["cleared"] is True
diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py
index 7b69d52a..a7f90725 100644
--- a/tests/a2a/test_pipeline_executor.py
+++ b/tests/a2a/test_pipeline_executor.py
@@ -14,10 +14,15 @@
from iac_code.a2a.executor import IacCodeA2AExecutor
from iac_code.a2a.metrics import NoOpA2AMetrics
+from iac_code.a2a.persistence import A2APersistenceStore
from iac_code.a2a.pipeline_journal import A2APipelineJournal
from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events
from iac_code.a2a.task_store import A2ATaskStore
+from iac_code.agent.message import ImageBlock
+from iac_code.pipeline.engine.cleanup import CleanupLedger, CleanupResource
from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType
+from iac_code.pipeline.engine.interrupt import InterruptVerdict
+from iac_code.pipeline.engine.user_input import PipelineUserInput
from iac_code.types.stream_events import AskUserQuestionEvent, TextDeltaEvent
from .fakes import FakeEventQueue, FakeRequestContext
@@ -26,10 +31,63 @@
AUTH_TEXT = "Authentication required. Configure credentials and retry."
+def test_active_sidecar_mismatch_error_exposes_jsonrpc_data() -> None:
+ from iac_code.a2a.pipeline_executor import _active_sidecar_mismatch_error
+
+ error = _active_sidecar_mismatch_error(
+ recoverable_task_id="task-owner",
+ context_id="ctx-1",
+ sidecar_status="running",
+ )
+
+ assert error.code == -32602
+ assert error.data == {
+ "recoverableTaskId": "task-owner",
+ "contextId": "ctx-1",
+ "sidecarStatus": "running",
+ }
+ assert "task-owner" in error.message
+
+
+def test_active_sidecar_mismatch_error_serializes_raw_jsonrpc_data() -> None:
+ from iac_code.a2a.jsonrpc_passthrough import install_jsonrpc_error_data_passthrough
+ from iac_code.a2a.pipeline_executor import _active_sidecar_mismatch_error
+
+ install_jsonrpc_error_data_passthrough()
+ from a2a.server.request_handlers.response_helpers import build_error_response
+
+ error = _active_sidecar_mismatch_error(
+ recoverable_task_id="task-owner",
+ context_id="ctx-1",
+ sidecar_status="waiting_input",
+ )
+
+ response = build_error_response("req-1", error)
+
+ assert response["error"]["code"] == -32602
+ assert response["error"]["data"] == {
+ "recoverableTaskId": "task-owner",
+ "contextId": "ctx-1",
+ "sidecarStatus": "waiting_input",
+ }
+
+
def dump(event):
return MessageToDict(event, preserving_proto_field_name=False)
+def image_interrupt_input() -> PipelineUserInput:
+ return PipelineUserInput(
+ content=[ImageBlock(media_type="image/png", data="aGVsbG8=")],
+ display_text="[Image input]",
+ has_images=True,
+ )
+
+
+def _display_text(value):
+ return value.display_text if isinstance(value, PipelineUserInput) else value
+
+
class FakePipeline:
def __init__(self, events, *, session_dir: Path) -> None:
self.events = events
@@ -46,14 +104,14 @@ def __init__(self, events, *, session_dir: Path) -> None:
self.handoff_summary = "handoff summary"
async def run(self, prompt: str):
- self.run_prompts.append(prompt)
+ self.run_prompts.append(_display_text(prompt))
for event in self.events:
if isinstance(event, BaseException):
raise event
yield event
async def resume(self, prompt: str):
- self.resume_prompts.append(prompt)
+ self.resume_prompts.append(_display_text(prompt))
for event in self.events:
if isinstance(event, BaseException):
raise event
@@ -61,7 +119,7 @@ async def resume(self, prompt: str):
def continue_from_sidecar(self, user_input: str | None = None):
self.continue_calls += 1
- self.continue_inputs.append(user_input)
+ self.continue_inputs.append(_display_text(user_input))
return self.run(user_input or "continued")
def clear_sidecar(self) -> None:
@@ -254,6 +312,96 @@ def fake_create_pipeline(*args, **kwargs):
assert messages[-1].content == "[Pipeline Handoff Context]\nPipeline: selling"
+@pytest.mark.asyncio
+async def test_executor_publishes_normal_handoff_ready_with_cleanup_resources(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ monkeypatch.setenv("IAC_CODE_MODE", "pipeline")
+ session_dir = tmp_path / "sidecar"
+ ledger = CleanupLedger(session_dir / "cleanup.yaml")
+ ledger.mark_cleanup_required(
+ [
+ CleanupResource(
+ provider="ros",
+ resource_type="stack",
+ resource_id="stack-123",
+ resource_name="selling-stack",
+ region_id="cn-hangzhou",
+ source_step_id="deploying",
+ )
+ ],
+ source_step_id="deploying",
+ reason="rollback from deploying",
+ )
+ fake_pipeline = FakePipeline(
+ [
+ PipelineEvent(
+ type=PipelineEventType.PIPELINE_COMPLETED,
+ step_id=None,
+ timestamp=1717821601.0,
+ data={"total_steps": 1},
+ ),
+ ],
+ session_dir=session_dir,
+ )
+ fake_pipeline.handoff_enabled = True
+ fake_pipeline.handoff_summary = "[Pipeline Handoff Context]\nPipeline: selling"
+ fake_pipeline.cleanup_ledger = lambda: ledger
+
+ def fake_create_pipeline(*args, **kwargs):
+ fake_pipeline._session_storage = kwargs["session_storage"]
+ fake_pipeline._session_id = kwargs["session_id"]
+ fake_pipeline._cwd = kwargs["cwd"]
+ return fake_pipeline
+
+ monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline)
+ monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime())
+
+ store = A2ATaskStore(metrics=NoOpA2AMetrics())
+ executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus")
+ queue = FakeEventQueue()
+
+ await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue)
+
+ pipeline_events = [
+ dump(event)["metadata"]["iac_code"]["pipeline"]
+ for event in queue.events
+ if isinstance(event, TaskStatusUpdateEvent)
+ and "pipeline" in dump(event).get("metadata", {}).get("iac_code", {})
+ ]
+ handoff = pipeline_events[-1]
+ cleanup = handoff["data"]["cleanup"]
+ assert cleanup["status"] == "pending"
+ assert cleanup["resourceCount"] == 1
+ assert cleanup["statusMessage"] == "Detected 1 rollback cleanup resources; starting cleanup."
+ assert "prompt" not in cleanup
+ assert "ledgerPath" not in cleanup
+ assert cleanup["resources"] == [
+ {
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "stack-123",
+ "resourceName": "selling-stack",
+ "regionId": "cn-hangzhou",
+ "sourceStepId": "deploying",
+ "cleanupStatus": "pending",
+ "progressStatus": None,
+ "lastError": None,
+ }
+ ]
+
+ snapshot = A2APipelineSnapshotStore(session_dir).load()
+ assert snapshot is not None
+ assert snapshot["cleanup"]["status"] == "pending"
+ assert snapshot["cleanup"]["resourceCount"] == 1
+ assert snapshot["normalHandoff"]["data"]["cleanup"]["resourceCount"] == 1
+ assert "prompt" not in snapshot["cleanup"]
+ assert "ledgerPath" not in snapshot["cleanup"]
+ assert "prompt" not in snapshot["normalHandoff"]["data"]["cleanup"]
+ assert "ledgerPath" not in snapshot["normalHandoff"]["data"]["cleanup"]
+
+
@pytest.mark.asyncio
async def test_executor_sets_pipeline_telemetry_correlation(
monkeypatch: pytest.MonkeyPatch,
@@ -272,8 +420,10 @@ async def test_executor_sets_pipeline_telemetry_correlation(
session_dir=tmp_path / "sidecar",
)
fake_pipeline.set_telemetry_correlation = MagicMock()
+ create_pipeline_kwargs = {}
def fake_create_pipeline(*args, **kwargs):
+ create_pipeline_kwargs.update(kwargs)
fake_pipeline._session_storage = kwargs["session_storage"]
fake_pipeline._session_id = kwargs["session_id"]
fake_pipeline._cwd = kwargs["cwd"]
@@ -295,6 +445,7 @@ def fake_create_pipeline(*args, **kwargs):
context_id="ctx-1",
pipeline_run_id="ctx-1",
)
+ assert create_pipeline_kwargs["surface"] == "a2a"
@pytest.mark.asyncio
@@ -775,12 +926,10 @@ async def test_executor_clears_previous_task_terminal_sidecar_and_runs_new_task(
@pytest.mark.parametrize(
("sidecar_status", "event_type", "event_status"),
[
- ("waiting_input", "input_required", "waiting_input"),
- ("running", "pipeline_started", "working"),
("completed", "pipeline_completed", "completed"),
],
)
-async def test_executor_replaces_restored_pipeline_when_sidecar_owner_mismatches(
+async def test_executor_replaces_terminal_restored_pipeline_when_sidecar_owner_mismatches(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
sidecar_status: str,
@@ -857,6 +1006,102 @@ def fake_create_pipeline(*args, **kwargs):
assert "".join(record.output_text) == "fresh output"
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("sidecar_status", "event_type", "event_status"),
+ [
+ ("waiting_input", "input_required", "waiting_input"),
+ ("running", "pipeline_started", "working"),
+ ],
+)
+async def test_executor_rejects_active_restored_pipeline_owner_mismatch_without_clearing(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ sidecar_status: str,
+ event_type: str,
+ event_status: str,
+) -> None:
+ from iac_code.a2a.pipeline_executor import (
+ IacCodeA2APipelineExecutor,
+ RecoverablePipelineInvalidParamsError,
+ )
+
+ monkeypatch.setenv("IAC_CODE_MODE", "pipeline")
+ session_dir = tmp_path / "sidecar"
+ owner_event = {
+ "schemaVersion": "1.0",
+ "extensionUri": "urn:iac-code:a2a:pipeline-events:v1",
+ "eventId": "evt-owner",
+ "sequence": 1,
+ "createdAt": "2026-06-08T10:00:00Z",
+ "eventType": event_type,
+ "scope": "pipeline",
+ "pipelineRunId": "ctx-1",
+ "taskId": "task-owner",
+ "contextId": "ctx-1",
+ "pipelineName": "selling",
+ "status": event_status,
+ "data": {"prompt": "owner choice"} if event_type == "input_required" else {},
+ }
+ journal = A2APipelineJournal(session_dir)
+ journal.append(owner_event)
+ A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([owner_event]))
+ restored_pipeline = FakePipeline(
+ [
+ TextDeltaEvent(text="stale restored output"),
+ PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}),
+ ],
+ session_dir=session_dir,
+ )
+ restored_pipeline.sidecar_status = sidecar_status
+ created_pipelines: list[FakePipeline] = []
+
+ def fake_create_pipeline(*args, **kwargs):
+ created_pipelines.append(restored_pipeline)
+ return restored_pipeline
+
+ monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline)
+ monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime())
+
+ store = A2ATaskStore(metrics=NoOpA2AMetrics())
+ executor = IacCodeA2APipelineExecutor(
+ task_store=store,
+ model="qwen3.6-plus",
+ metrics=NoOpA2AMetrics(),
+ artifact_store=None,
+ push_notifier=None,
+ permission_resolver=None,
+ auto_approve_permissions=False,
+ thinking_exposure_types=None,
+ )
+
+ with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info:
+ await executor.execute(
+ context=FakeRequestContext(
+ task_id="task-new",
+ context_id="ctx-1",
+ text="new request",
+ metadata={"iac_code": {"cwd": str(tmp_path)}},
+ ),
+ event_queue=FakeEventQueue(),
+ task=await store.get_or_create_task(task_id="task-new", context_id="ctx-1"),
+ task_id="task-new",
+ context_id="ctx-1",
+ cwd=str(tmp_path),
+ prompt="new request",
+ )
+
+ assert exc_info.value.data == {
+ "recoverableTaskId": "task-owner",
+ "contextId": "ctx-1",
+ "sidecarStatus": sidecar_status,
+ }
+ assert len(created_pipelines) == 1
+ assert restored_pipeline.clear_sidecar_calls == 0
+ assert restored_pipeline.run_prompts == []
+ assert journal.read_all() == [owner_event]
+
+
@pytest.mark.asyncio
async def test_executor_keeps_a2a_metadata_when_mismatch_clears_pipeline_sidecar(
monkeypatch: pytest.MonkeyPatch,
@@ -1602,10 +1847,12 @@ async def test_executor_does_not_resume_nonterminal_sidecar_when_a2a_state_is_te
@pytest.mark.asyncio
-async def test_executor_clears_previous_task_waiting_sidecar_and_runs_new_task(
+async def test_executor_rejects_previous_task_waiting_sidecar_without_starting_new_task(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
+ from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError
+
monkeypatch.setenv("IAC_CODE_MODE", "pipeline")
session_dir = tmp_path / "sidecar"
old_input = {
@@ -1638,19 +1885,25 @@ async def test_executor_clears_previous_task_waiting_sidecar_and_runs_new_task(
executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus")
- await executor.execute(
- FakeRequestContext(
- task_id="task-new",
- context_id="ctx-1",
- text="new request",
- metadata={"iac_code": {"cwd": str(tmp_path)}},
- ),
- FakeEventQueue(),
- )
+ with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info:
+ await executor.execute(
+ FakeRequestContext(
+ task_id="task-new",
+ context_id="ctx-1",
+ text="new request",
+ metadata={"iac_code": {"cwd": str(tmp_path)}},
+ ),
+ FakeEventQueue(),
+ )
- assert fake_pipeline.clear_sidecar_calls == 1
+ assert exc_info.value.data == {
+ "recoverableTaskId": "task-old",
+ "contextId": "ctx-1",
+ "sidecarStatus": "waiting_input",
+ }
+ assert fake_pipeline.clear_sidecar_calls == 0
assert fake_pipeline.resume_prompts == []
- assert fake_pipeline.run_prompts == ["new request"]
+ assert fake_pipeline.run_prompts == []
@pytest.mark.asyncio
@@ -1668,6 +1921,8 @@ async def test_executor_does_not_attach_current_sidecar_to_historical_task(
event_type: str,
event_status: str,
) -> None:
+ from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError
+
monkeypatch.setenv("IAC_CODE_MODE", "pipeline")
session_dir = tmp_path / "sidecar"
old_event = {
@@ -1712,21 +1967,27 @@ async def test_executor_does_not_attach_current_sidecar_to_historical_task(
executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus")
- await executor.execute(
- FakeRequestContext(
- task_id="task-old",
- context_id="ctx-1",
- text="old followup",
- metadata={"iac_code": {"cwd": str(tmp_path)}},
- ),
- FakeEventQueue(),
- )
+ with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info:
+ await executor.execute(
+ FakeRequestContext(
+ task_id="task-old",
+ context_id="ctx-1",
+ text="old followup",
+ metadata={"iac_code": {"cwd": str(tmp_path)}},
+ ),
+ FakeEventQueue(),
+ )
- assert fake_pipeline.clear_sidecar_calls == 1
+ assert exc_info.value.data == {
+ "recoverableTaskId": "task-current",
+ "contextId": "ctx-1",
+ "sidecarStatus": sidecar_status,
+ }
+ assert fake_pipeline.clear_sidecar_calls == 0
assert fake_pipeline.resume_prompts == []
assert fake_pipeline.continue_calls == 0
- assert fake_pipeline.run_prompts == ["old followup"]
- assert journal.read_all()[-1]["taskId"] == "task-old"
+ assert fake_pipeline.run_prompts == []
+ assert journal.read_all()[-1]["taskId"] == "task-current"
@pytest.mark.asyncio
@@ -1891,10 +2152,12 @@ async def test_executor_routes_waiting_input_pause_confirmation_through_interrup
@pytest.mark.asyncio
-async def test_executor_clears_previous_task_running_sidecar_and_runs_new_task(
+async def test_executor_rejects_previous_task_running_sidecar_without_starting_new_task(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
+ from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError
+
monkeypatch.setenv("IAC_CODE_MODE", "pipeline")
session_dir = tmp_path / "sidecar"
old_running = {
@@ -1927,37 +2190,144 @@ async def test_executor_clears_previous_task_running_sidecar_and_runs_new_task(
executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus")
- await executor.execute(
- FakeRequestContext(
- task_id="task-new",
- context_id="ctx-1",
- text="new request",
- metadata={"iac_code": {"cwd": str(tmp_path)}},
- ),
- FakeEventQueue(),
- )
+ with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info:
+ await executor.execute(
+ FakeRequestContext(
+ task_id="task-new",
+ context_id="ctx-1",
+ text="new request",
+ metadata={"iac_code": {"cwd": str(tmp_path)}},
+ ),
+ FakeEventQueue(),
+ )
- assert fake_pipeline.clear_sidecar_calls == 1
+ assert exc_info.value.data == {
+ "recoverableTaskId": "task-old",
+ "contextId": "ctx-1",
+ "sidecarStatus": "running",
+ }
+ assert fake_pipeline.clear_sidecar_calls == 0
assert fake_pipeline.continue_calls == 0
- assert fake_pipeline.run_prompts == ["new request"]
+ assert fake_pipeline.run_prompts == []
@pytest.mark.asyncio
-async def test_pipeline_executor_routes_second_prompt_as_interrupt(tmp_path: Path) -> None:
- from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator
- from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor
- from iac_code.a2a.pipeline_journal import A2APipelineJournal
- from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore
- from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher
+async def test_executor_rejected_active_sidecar_mismatch_does_not_persist_new_working_task(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError
- class InterruptiblePipeline(FakePipeline):
- def __init__(self, *, session_dir: Path) -> None:
- super().__init__([TextDeltaEvent(text="running")], session_dir=session_dir)
- self.interrupts: list[str] = []
+ monkeypatch.setenv("IAC_CODE_MODE", "pipeline")
+ persistence = A2APersistenceStore(tmp_path / "a2a")
+ session_dir = tmp_path / "sidecar"
+ owner_event = {
+ "schemaVersion": "1.0",
+ "extensionUri": "urn:iac-code:a2a:pipeline-events:v1",
+ "eventId": "evt-owner-running",
+ "sequence": 1,
+ "createdAt": "2026-06-08T10:00:00Z",
+ "eventType": "pipeline_started",
+ "scope": "pipeline",
+ "pipelineRunId": "ctx-1",
+ "taskId": "task-owner",
+ "contextId": "ctx-1",
+ "pipelineName": "selling",
+ "status": "working",
+ "data": {},
+ }
+ A2APipelineJournal(session_dir).append(owner_event)
+ A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([owner_event]))
+ fake_pipeline = FakePipeline(
+ [
+ TextDeltaEvent(text="new output"),
+ PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}),
+ ],
+ session_dir=session_dir,
+ )
+ fake_pipeline.sidecar_status = "running"
+ monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline)
+ monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime())
- async def handle_user_interrupt(self, message: str) -> SimpleNamespace:
- self.interrupts.append(message)
- return SimpleNamespace(
+ task_store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
+ executor = IacCodeA2AExecutor(task_store=task_store, model="qwen3.6-plus")
+
+ with pytest.raises(RecoverablePipelineInvalidParamsError):
+ await executor.execute(
+ FakeRequestContext(
+ task_id="task-new",
+ context_id="ctx-1",
+ text="new request",
+ metadata={"iac_code": {"cwd": str(tmp_path)}},
+ ),
+ FakeEventQueue(),
+ )
+
+ assert fake_pipeline.clear_sidecar_calls == 0
+ assert fake_pipeline.run_prompts == []
+ rejected_task = persistence.load_task("task-new")
+ assert rejected_task is not None
+ assert rejected_task.state != "working"
+ assert [task.task_id for task in persistence.list_tasks() if task.state == "working"] == []
+
+
+def test_cleanup_handoff_missing_ledger_ignores_empty_public_cleanup_snapshot(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_executor import _pipeline_cleanup_handoff_data_from_session
+
+ cleanup = _pipeline_cleanup_handoff_data_from_session(
+ cwd=str(tmp_path),
+ session_id="session-empty-cleanup",
+ public_snapshot={"cleanup": {"resourceCount": 0, "resources": [], "status": ""}},
+ )
+
+ assert cleanup is None
+
+
+def test_cleanup_handoff_missing_ledger_does_not_reconstruct_prompt_from_public_snapshot(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_executor import _pipeline_cleanup_handoff_data_from_session
+
+ cleanup = _pipeline_cleanup_handoff_data_from_session(
+ cwd=str(tmp_path),
+ session_id="session-public-cleanup-only",
+ public_snapshot={
+ "cleanup": {
+ "resourceCount": 1,
+ "resources": [
+ {
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "stack-public-only",
+ "cleanupStatus": "pending",
+ }
+ ],
+ "status": "pending",
+ }
+ },
+ )
+
+ assert cleanup is not None
+ assert cleanup["status"] == "unavailable"
+ assert "prompt" not in cleanup
+ assert "resources" not in cleanup
+ assert "stack-public-only" not in repr(cleanup)
+
+
+@pytest.mark.asyncio
+async def test_pipeline_executor_routes_second_prompt_as_interrupt(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator
+ from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor
+ from iac_code.a2a.pipeline_journal import A2APipelineJournal
+ from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore
+ from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher
+
+ class InterruptiblePipeline(FakePipeline):
+ def __init__(self, *, session_dir: Path) -> None:
+ super().__init__([TextDeltaEvent(text="running")], session_dir=session_dir)
+ self.interrupts: list[str] = []
+
+ async def handle_user_interrupt(self, message: str) -> SimpleNamespace:
+ self.interrupts.append(message)
+ return SimpleNamespace(
action="supplement",
reason="added context",
rollback_target=None,
@@ -2942,7 +3312,7 @@ async def handle_user_interrupt(self, message: str) -> SimpleNamespace:
task_id="task-1",
context_id="ctx-1",
cwd=str(tmp_path),
- prompt="Nginx 网站",
+ pipeline_input="Nginx 网站",
preserve_task_record=True,
)
@@ -3035,7 +3405,7 @@ async def handle_user_interrupt(self, message: str) -> SimpleNamespace:
task_id="task-1",
context_id="ctx-1",
cwd=str(tmp_path),
- prompt="Nginx 网站",
+ pipeline_input="Nginx 网站",
preserve_task_record=True,
)
@@ -3351,6 +3721,120 @@ def test_waiting_input_task_id_from_sidecar_accepts_candidate_selection(tmp_path
assert waiting_input_task_id_from_sidecar(cwd=str(cwd), session_id=session_id, context_id=context_id) == "task-1"
+def test_cancel_waiting_input_sidecar_appends_cancel_handoff_as_durable_group(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from iac_code.a2a.pipeline_executor import cancel_waiting_input_task_from_sidecar
+ from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session
+
+ cwd = tmp_path / "workspace"
+ session_id = "session-ctx-1"
+ context_id = "ctx-1"
+ pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id)
+ pending = {
+ "schemaVersion": "1.0",
+ "extensionUri": "urn:iac-code:a2a:pipeline-events:v1",
+ "eventId": "evt-selection",
+ "sequence": 1,
+ "createdAt": "2026-06-08T10:00:00Z",
+ "eventType": "input_required",
+ "scope": "step",
+ "pipelineRunId": context_id,
+ "taskId": "task-1",
+ "contextId": context_id,
+ "pipelineName": "selling",
+ "status": "input_required",
+ "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1},
+ "input": {
+ "inputId": "input-confirm_and_select-1",
+ "kind": "candidate_selection",
+ "prompt": "请选择方案",
+ "options": [{"name": "方案A", "candidate_index": 0}],
+ },
+ }
+ A2APipelineJournal(pipeline_dir).append(pending)
+ A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending]))
+ append_many_calls = []
+ original_append_many = A2APipelineJournal.append_many
+
+ def recording_append_many(self, events, durable: bool = False):
+ append_many_calls.append(([event["eventType"] for event in events], durable))
+ return original_append_many(self, events, durable=durable)
+
+ monkeypatch.setattr(A2APipelineJournal, "append_many", recording_append_many)
+
+ canceled = cancel_waiting_input_task_from_sidecar(
+ cwd=str(cwd),
+ session_id=session_id,
+ context_id=context_id,
+ task_id="task-1",
+ reason="user canceled",
+ )
+
+ assert canceled is True
+ assert append_many_calls[-1] == (["pipeline_canceled", "pipeline_handoff_ready"], True)
+ events = A2APipelineJournal(pipeline_dir).read_all()
+ assert [event["eventType"] for event in events[-2:]] == ["pipeline_canceled", "pipeline_handoff_ready"]
+
+
+def test_cancel_waiting_input_sidecar_returns_false_when_durable_group_fails(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from iac_code.a2a.pipeline_executor import cancel_waiting_input_task_from_sidecar
+ from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session
+
+ cwd = tmp_path / "workspace"
+ session_id = "session-ctx-1"
+ context_id = "ctx-1"
+ pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id)
+ pending = {
+ "schemaVersion": "1.0",
+ "extensionUri": "urn:iac-code:a2a:pipeline-events:v1",
+ "eventId": "evt-selection",
+ "sequence": 1,
+ "createdAt": "2026-06-08T10:00:00Z",
+ "eventType": "input_required",
+ "scope": "step",
+ "pipelineRunId": context_id,
+ "taskId": "task-1",
+ "contextId": context_id,
+ "pipelineName": "selling",
+ "status": "input_required",
+ "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1},
+ "input": {
+ "inputId": "input-confirm_and_select-1",
+ "kind": "candidate_selection",
+ "prompt": "请选择方案",
+ "options": [{"name": "方案A", "candidate_index": 0}],
+ },
+ }
+ A2APipelineJournal(pipeline_dir).append(pending)
+ A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending]))
+
+ def fail_append_many(self, events, durable: bool = False):
+ assert durable is True
+ assert [event["eventType"] for event in events] == ["pipeline_canceled", "pipeline_handoff_ready"]
+ raise OSError("journal locked")
+
+ monkeypatch.setattr(A2APipelineJournal, "append_many", fail_append_many)
+
+ canceled = cancel_waiting_input_task_from_sidecar(
+ cwd=str(cwd),
+ session_id=session_id,
+ context_id=context_id,
+ task_id="task-1",
+ reason="user canceled",
+ )
+
+ assert canceled is False
+ assert [event["eventType"] for event in A2APipelineJournal(pipeline_dir).read_all()] == ["input_required"]
+ snapshot = A2APipelineSnapshotStore(pipeline_dir).load()
+ assert snapshot is not None
+ assert snapshot["status"] == "waiting_input"
+
+
@pytest.mark.asyncio
async def test_executor_recovers_pending_ask_from_journal_when_snapshot_is_missing(
monkeypatch: pytest.MonkeyPatch,
@@ -4283,3 +4767,363 @@ async def test_same_task_non_interruptible_active_context_preserves_active_recor
final_status = _status_events(queue)[-1]["status"]
assert final_status["state"] == "TASK_STATE_FAILED"
assert final_status["message"]["parts"][0]["text"] == "Task is already working."
+
+
+@pytest.mark.asyncio
+async def test_active_pipeline_interrupt_receives_structured_image_input(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor
+
+ store = A2ATaskStore(metrics=NoOpA2AMetrics())
+ task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1")
+ task.active_task = asyncio.current_task()
+ ctx = await store.get_or_create_context(
+ context_id="ctx-1",
+ cwd=str(tmp_path),
+ runtime_factory=lambda session_id: _fake_runtime(),
+ )
+ ctx.active_task_id = "task-1"
+ received = []
+
+ class InterruptPipeline(FakePipeline):
+ async def handle_user_interrupt(self, message):
+ received.append(message)
+ return InterruptVerdict(action="continue", reason="keep going")
+
+ def pause_agent_loops(self) -> None:
+ pass
+
+ def resume_agent_loops(self) -> None:
+ pass
+
+ pipeline = InterruptPipeline([], session_dir=tmp_path / "pipeline")
+ publisher = SimpleNamespace(
+ publish_interrupt_received=AsyncMock(),
+ publish_interrupt=AsyncMock(),
+ journal=A2APipelineJournal(tmp_path / "pipeline"),
+ snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"),
+ )
+ ctx.runtime = SimpleNamespace(
+ agent_runtime=_fake_runtime(),
+ pipeline=pipeline,
+ publisher=publisher,
+ current_stream=None,
+ restart_after_interrupt=False,
+ pause_after_interrupt=False,
+ restart_requested=asyncio.Event(),
+ )
+ store.mirror_context(ctx)
+ pipeline_input = image_interrupt_input()
+
+ executor = IacCodeA2APipelineExecutor(
+ task_store=store,
+ model="qwen3.6-plus",
+ metrics=NoOpA2AMetrics(),
+ artifact_store=None,
+ push_notifier=None,
+ permission_resolver=None,
+ auto_approve_permissions=False,
+ thinking_exposure_types=None,
+ )
+ await executor.execute(
+ context=FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}),
+ event_queue=FakeEventQueue(),
+ task=task,
+ task_id="task-1",
+ context_id="ctx-1",
+ cwd=str(tmp_path),
+ pipeline_input=pipeline_input,
+ )
+
+ assert received == [pipeline_input]
+ publisher.publish_interrupt_received.assert_awaited_once_with(prompt="[Image input]")
+
+
+@pytest.mark.asyncio
+async def test_active_pending_question_answer_preserves_image_input(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, _PendingAskUserQuestion
+
+ future = asyncio.get_running_loop().create_future()
+ injected = []
+
+ class Pipeline:
+ def inject_pending_question_supplement(self, message, *, envelope):
+ injected.append((message, envelope))
+
+ runtime = SimpleNamespace(
+ pending_question=_PendingAskUserQuestion(
+ event=AskUserQuestionEvent(
+ tool_use_id="toolu_1",
+ question="Upload diagram",
+ options=[],
+ response_future=future,
+ ),
+ envelope={"scope": "pipeline", "inputId": "ask-toolu_1"},
+ ),
+ pipeline=Pipeline(),
+ publisher=SimpleNamespace(
+ publish_manual=AsyncMock(return_value=object()),
+ ),
+ )
+ pipeline_input = image_interrupt_input()
+ executor = IacCodeA2APipelineExecutor(
+ task_store=A2ATaskStore(metrics=NoOpA2AMetrics()),
+ model="qwen3.6-plus",
+ metrics=NoOpA2AMetrics(),
+ artifact_store=None,
+ push_notifier=None,
+ permission_resolver=None,
+ auto_approve_permissions=False,
+ thinking_exposure_types=None,
+ )
+
+ result = await executor._route_pending_question_answer(runtime, pipeline_input)
+
+ assert result == "answered"
+ answer = future.result()
+ assert answer == {"selected_id": "", "selected_label": "", "free_text": "[Image input]"}
+ assert injected == [(pipeline_input.content, {"scope": "pipeline", "inputId": "ask-toolu_1"})]
+
+
+@pytest.mark.asyncio
+async def test_active_pending_question_image_injection_failure_is_not_marked_answered(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, _PendingAskUserQuestion
+
+ future = asyncio.get_running_loop().create_future()
+
+ class Pipeline:
+ def inject_pending_question_supplement(self, message, *, envelope):
+ return False
+
+ runtime = SimpleNamespace(
+ pending_question=_PendingAskUserQuestion(
+ event=AskUserQuestionEvent(
+ tool_use_id="toolu_1",
+ question="Upload diagram",
+ options=[],
+ response_future=future,
+ ),
+ envelope={"scope": "pipeline", "inputId": "ask-toolu_1"},
+ ),
+ pipeline=Pipeline(),
+ publisher=SimpleNamespace(
+ publish_manual=AsyncMock(return_value=object()),
+ ),
+ )
+ pipeline_input = image_interrupt_input()
+ executor = IacCodeA2APipelineExecutor(
+ task_store=A2ATaskStore(metrics=NoOpA2AMetrics()),
+ model="qwen3.6-plus",
+ metrics=NoOpA2AMetrics(),
+ artifact_store=None,
+ push_notifier=None,
+ permission_resolver=None,
+ auto_approve_permissions=False,
+ thinking_exposure_types=None,
+ )
+
+ with pytest.raises(RuntimeError, match="image supplement could not be delivered"):
+ await executor._route_pending_question_answer(runtime, pipeline_input)
+
+ assert future.done() is False
+ assert runtime.pending_question is not None
+
+
+@pytest.mark.asyncio
+async def test_active_pending_question_image_injection_failure_restores_snapshot_pending_input(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator
+ from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, _PendingAskUserQuestion
+ from iac_code.a2a.pipeline_journal import A2APipelineJournal
+ from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore
+ from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher
+
+ future = asyncio.get_running_loop().create_future()
+
+ class Pipeline:
+ def inject_pending_question_supplement(self, message, *, envelope):
+ return False
+
+ publisher = PipelineA2AEventPublisher(
+ event_queue=FakeEventQueue(),
+ translator=PipelineEventTranslator(
+ PipelineA2AContext(
+ pipeline_run_id="ctx-1",
+ task_id="task-1",
+ context_id="ctx-1",
+ pipeline_name="selling",
+ )
+ ),
+ journal=A2APipelineJournal(tmp_path / "pipeline"),
+ snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"),
+ )
+ await publisher.publish_manual(
+ "input_required",
+ "pipeline",
+ status="input_required",
+ data={
+ "kind": "ask_user_question",
+ "inputId": "ask-toolu_1",
+ "toolUseId": "toolu_1",
+ "question": "Upload diagram",
+ "prompt": "Upload diagram",
+ "options": [],
+ "required": True,
+ },
+ )
+ assert publisher.snapshot_store.load()["pendingInput"]["inputId"] == "ask-toolu_1"
+
+ runtime = SimpleNamespace(
+ pending_question=_PendingAskUserQuestion(
+ event=AskUserQuestionEvent(
+ tool_use_id="toolu_1",
+ question="Upload diagram",
+ options=[],
+ response_future=future,
+ ),
+ envelope={"scope": "pipeline", "inputId": "ask-toolu_1"},
+ ),
+ pipeline=Pipeline(),
+ publisher=publisher,
+ )
+ executor = IacCodeA2APipelineExecutor(
+ task_store=A2ATaskStore(metrics=NoOpA2AMetrics()),
+ model="qwen3.6-plus",
+ metrics=NoOpA2AMetrics(),
+ artifact_store=None,
+ push_notifier=None,
+ permission_resolver=None,
+ auto_approve_permissions=False,
+ thinking_exposure_types=None,
+ )
+
+ with pytest.raises(RuntimeError, match="image supplement could not be delivered"):
+ await executor._route_pending_question_answer(runtime, image_interrupt_input())
+
+ snapshot = publisher.snapshot_store.load()
+ assert snapshot["status"] == "waiting_input"
+ assert snapshot["pendingInput"]["inputId"] == "ask-toolu_1"
+ assert future.done() is False
+ assert runtime.pending_question is not None
+
+
+@pytest.mark.asyncio
+async def test_execute_reports_active_pending_question_image_injection_failure(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator
+ from iac_code.a2a.pipeline_executor import (
+ A2APipelineRuntime,
+ IacCodeA2APipelineExecutor,
+ _PendingAskUserQuestion,
+ )
+ from iac_code.a2a.pipeline_journal import A2APipelineJournal
+ from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore
+ from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher
+
+ future = asyncio.get_running_loop().create_future()
+
+ class Pipeline:
+ def inject_pending_question_supplement(self, message, *, envelope):
+ return False
+
+ queue = FakeEventQueue()
+ publisher = PipelineA2AEventPublisher(
+ event_queue=queue,
+ translator=PipelineEventTranslator(
+ PipelineA2AContext(
+ pipeline_run_id="ctx-1",
+ task_id="task-1",
+ context_id="ctx-1",
+ pipeline_name="selling",
+ )
+ ),
+ journal=A2APipelineJournal(tmp_path / "pipeline"),
+ snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"),
+ )
+ publisher.publish_manual = AsyncMock(return_value=object()) # type: ignore[method-assign]
+ runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=Pipeline(), publisher=publisher)
+ runtime.pending_question = _PendingAskUserQuestion(
+ event=AskUserQuestionEvent(
+ tool_use_id="toolu_1",
+ question="Upload diagram",
+ options=[],
+ response_future=future,
+ ),
+ envelope={"scope": "pipeline", "inputId": "ask-toolu_1"},
+ )
+ store = A2ATaskStore(metrics=NoOpA2AMetrics())
+ task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1")
+ task.state = "input-required"
+ ctx = await store.get_or_create_context(
+ context_id="ctx-1",
+ cwd=str(tmp_path),
+ runtime_factory=lambda _session_id: _fake_runtime(),
+ )
+ ctx.runtime = runtime
+ ctx.active_task_id = "task-1"
+ executor = IacCodeA2APipelineExecutor(
+ task_store=store,
+ model="qwen3.6-plus",
+ metrics=NoOpA2AMetrics(),
+ artifact_store=None,
+ push_notifier=None,
+ permission_resolver=None,
+ auto_approve_permissions=False,
+ thinking_exposure_types=None,
+ )
+
+ await executor.execute(
+ context=FakeRequestContext(task_id="task-1", context_id="ctx-1"),
+ event_queue=queue,
+ task=task,
+ task_id="task-1",
+ context_id="ctx-1",
+ cwd=str(tmp_path),
+ pipeline_input=image_interrupt_input(),
+ )
+
+ states = [dump(event)["status"]["state"] for event in queue.events if isinstance(event, TaskStatusUpdateEvent)]
+ assert "TASK_STATE_FAILED" in states
+ assert future.done() is False
+ assert runtime.pending_question is not None
+
+
+@pytest.mark.asyncio
+async def test_pending_ask_user_question_resume_preserves_image_input(tmp_path: Path) -> None:
+ from iac_code.a2a.pipeline_executor import _resume_pending_ask_user_question_stream
+
+ pipeline_input = image_interrupt_input()
+ received = {}
+
+ class AskPipeline(FakePipeline):
+ sidecar_status = "waiting_input"
+
+ async def resume_ask_user_question(self, answer, **kwargs):
+ received["answer"] = answer
+ received["supplemental_input"] = kwargs.get("supplemental_input")
+ yield PipelineEvent(
+ type=PipelineEventType.PIPELINE_COMPLETED,
+ step_id="ask",
+ timestamp=0.0,
+ data={"total_steps": 1},
+ )
+
+ pending_input = {
+ "kind": "ask_user_question",
+ "toolUseId": "toolu_1",
+ "inputId": "ask-toolu_1",
+ }
+ pipeline = AskPipeline([], session_dir=tmp_path / "pipeline")
+ publisher = SimpleNamespace(
+ snapshot_store=SimpleNamespace(load=lambda: {"status": "waiting_input"}),
+ publish_manual=AsyncMock(return_value=object()),
+ )
+
+ stream = _resume_pending_ask_user_question_stream(
+ pipeline=pipeline,
+ publisher=publisher,
+ pending_input=pending_input,
+ prompt="[Image input]",
+ pipeline_input=pipeline_input,
+ )
+ events = [event async for event in stream]
+
+ assert events
+ assert received["supplemental_input"] == pipeline_input
diff --git a/tests/a2a/test_pipeline_journal.py b/tests/a2a/test_pipeline_journal.py
index 16bf1986..19a997dd 100644
--- a/tests/a2a/test_pipeline_journal.py
+++ b/tests/a2a/test_pipeline_journal.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import pytest
+
from iac_code.a2a.pipeline_journal import A2APipelineJournal
@@ -37,6 +39,42 @@ def test_read_after_filters_by_sequence(tmp_path) -> None:
assert [event["eventId"] for event in journal.read_after(1)] == ["evt-2", "evt-3"]
+def test_append_many_replays_group_as_events(tmp_path) -> None:
+ journal = A2APipelineJournal(tmp_path / "pipeline")
+
+ journal.append_many([_event(1, "evt-cancel"), _event(2, "evt-handoff")], durable=True)
+
+ assert [event["eventId"] for event in journal.read_all_strict()] == ["evt-cancel", "evt-handoff"]
+
+
+def test_append_many_sorts_group_events_with_regular_events(tmp_path) -> None:
+ journal = A2APipelineJournal(tmp_path / "pipeline")
+
+ journal.append(_event(3, "evt-after"))
+ journal.append_many([_event(1, "evt-cancel"), _event(2, "evt-handoff")], durable=True)
+
+ assert [event["eventId"] for event in journal.read_all()] == ["evt-cancel", "evt-handoff", "evt-after"]
+
+
+@pytest.mark.parametrize("write_method", ["append", "append_many"])
+def test_durable_append_fsyncs_parent_directory_when_journal_is_created(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+ write_method: str,
+) -> None:
+ journal = A2APipelineJournal(tmp_path / "pipeline")
+ calls = []
+
+ monkeypatch.setattr("iac_code.a2a.pipeline_journal.fsync_parent_dir", calls.append, raising=False)
+
+ if write_method == "append":
+ journal.append(_event(1, "evt-1"), durable=True)
+ else:
+ journal.append_many([_event(1, "evt-1"), _event(2, "evt-2")], durable=True)
+
+ assert calls == [journal.path]
+
+
def test_invalid_json_lines_are_skipped(tmp_path) -> None:
journal = A2APipelineJournal(tmp_path / "pipeline")
journal.append(_event(1, "evt-1"))
diff --git a/tests/a2a/test_pipeline_recovery.py b/tests/a2a/test_pipeline_recovery.py
index bd5f7a98..2250646a 100644
--- a/tests/a2a/test_pipeline_recovery.py
+++ b/tests/a2a/test_pipeline_recovery.py
@@ -60,6 +60,49 @@ async def test_recovery_returns_snapshot_and_replay_events(tmp_path) -> None:
assert [event["eventId"] for event in state["events"]] == ["evt-2"]
+@pytest.mark.asyncio
+async def test_recovery_keeps_pipeline_warning_visible_after_snapshot_sequence(tmp_path) -> None:
+ persistence = A2APersistenceStore(tmp_path / "a2a")
+ store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
+ await store.get_or_create_context(
+ context_id="ctx-1",
+ cwd=str(tmp_path),
+ runtime_factory=lambda session_id: object(),
+ )
+ context = await store.get_context_record("ctx-1")
+ pipeline_dir = SessionStorage().session_dir(str(tmp_path), context.session_id) / "pipeline"
+ started = _event(1, "evt-1")
+ warning = _event(2, "evt-warning")
+ warning["eventType"] = "pipeline_warning"
+ warning["data"] = {
+ "reason": "cleanup_tracking_unavailable",
+ "operation": "record_observed",
+ "ledger_path": "/Users/alice/.iac-code/projects/demo/cleanup.yaml",
+ "load_error": "while parsing /Users/alice/.iac-code/projects/demo/cleanup.yaml",
+ }
+ journal = A2APipelineJournal(pipeline_dir)
+ journal.append(started)
+ journal.append(warning)
+ A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([started, warning]))
+
+ service = A2APipelineRecoveryService(task_store=store)
+ state = await service.get_state(context_id="ctx-1")
+
+ assert state["events"] == []
+ assert state["snapshot"]["lastSequence"] == 2
+ assert state["snapshot"]["control"]["warningHistory"][0]["eventId"] == "evt-warning"
+ assert state["snapshot"]["control"]["warningHistory"][0]["data"]["reason"] == "cleanup_tracking_unavailable"
+ assert "ledger_path" not in state["snapshot"]["control"]["warningHistory"][0]["data"]
+ assert "load_error" not in state["snapshot"]["control"]["warningHistory"][0]["data"]
+
+ replay_state = await service.get_state(context_id="ctx-1", after_sequence=1)
+
+ assert replay_state["events"][0]["eventType"] == "pipeline_warning"
+ assert replay_state["events"][0]["data"]["reason"] == "cleanup_tracking_unavailable"
+ assert "ledger_path" not in replay_state["events"][0]["data"]
+ assert "load_error" not in replay_state["events"][0]["data"]
+
+
@pytest.mark.asyncio
async def test_recovery_sanitizes_legacy_artifact_file_uris_from_snapshot_and_replay(tmp_path) -> None:
persistence = A2APersistenceStore(tmp_path / "a2a")
@@ -245,6 +288,108 @@ async def test_recovery_rejects_task_id_when_pipeline_state_belongs_to_different
await service.get_state(task_id="task-2")
+@pytest.mark.asyncio
+async def test_recovery_resolves_cleanup_snapshot_from_normal_delivery_task_id(tmp_path) -> None:
+ persistence = A2APersistenceStore(tmp_path / "a2a")
+ persistence.save_task(A2ATaskSnapshot(task_id="task-pipeline", context_id="ctx-1", state="completed"))
+ persistence.save_task(A2ATaskSnapshot(task_id="task-normal", context_id="ctx-1", state="input-required"))
+ persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id="session-1", cwd=str(tmp_path)))
+ pipeline_dir = SessionStorage().session_dir(str(tmp_path), "session-1") / "pipeline"
+ raw_error = (
+ "DeleteStack failed AccessKeySecret=super-secret token=sk-live-1234567890 "
+ "at /Users/alice/.iac-code/projects/session/pipeline/cleanup.yaml"
+ )
+ pipeline_started = _event_for_task(1, "evt-pipeline-started", task_id="task-pipeline")
+ cleanup_started = _event_for_task(2, "evt-cleanup-started", task_id="task-pipeline")
+ cleanup_started.update(
+ {
+ "eventType": "cleanup_started",
+ "scope": "cleanup",
+ "deliveryTaskId": "task-normal",
+ "data": {
+ "status": "started",
+ "resourceCount": 1,
+ "prompt": "hidden cleanup prompt for stack-123",
+ "ledgerPath": "/Users/alice/.iac-code/projects/session/pipeline/cleanup.yaml",
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "stack-123",
+ "regionId": "cn-hangzhou",
+ "cleanupStatus": "started",
+ "progressStatus": "DELETE_STARTED",
+ "lastError": raw_error,
+ },
+ }
+ )
+ journal = A2APipelineJournal(pipeline_dir)
+ journal.append(pipeline_started)
+ journal.append(cleanup_started)
+ A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pipeline_started, cleanup_started]))
+ store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
+ service = A2APipelineRecoveryService(task_store=store)
+
+ state = await service.get_state(task_id="task-normal", after_sequence=0)
+
+ assert state["snapshot"]["taskId"] == "task-pipeline"
+ assert state["snapshot"]["cleanup"]["status"] == "started"
+ assert state["snapshot"]["cleanup"]["resources"][0]["resourceId"] == "stack-123"
+ assert "prompt" not in state["snapshot"]["cleanup"]
+ assert "ledgerPath" not in state["snapshot"]["cleanup"]
+ assert "prompt" not in state["snapshot"]["cleanup"]["history"][0]["data"]
+ assert "ledgerPath" not in state["snapshot"]["cleanup"]["history"][0]["data"]
+ assert raw_error not in state["snapshot"]["cleanup"]["history"][0]["data"]["lastError"]
+ assert [event["eventId"] for event in state["events"]] == ["evt-cleanup-started"]
+ assert "prompt" not in state["events"][0]["data"]
+ assert "ledgerPath" not in state["events"][0]["data"]
+ assert raw_error not in state["events"][0]["data"]["lastError"]
+ rendered = json.dumps(state, ensure_ascii=False)
+ assert "super-secret" not in rendered
+ assert "sk-live-1234567890" not in rendered
+ assert "/Users/alice" not in rendered
+
+
+@pytest.mark.asyncio
+async def test_recovery_by_delivery_task_catches_up_stale_pipeline_snapshot(tmp_path) -> None:
+ persistence = A2APersistenceStore(tmp_path / "a2a")
+ persistence.save_task(A2ATaskSnapshot(task_id="task-pipeline", context_id="ctx-1", state="completed"))
+ persistence.save_task(A2ATaskSnapshot(task_id="task-normal", context_id="ctx-1", state="input-required"))
+ persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id="session-1", cwd=str(tmp_path)))
+ pipeline_dir = SessionStorage().session_dir(str(tmp_path), "session-1") / "pipeline"
+ pipeline_started = _event_for_task(1, "evt-pipeline-started", task_id="task-pipeline")
+ cleanup_started = _event_for_task(2, "evt-cleanup-started", task_id="task-pipeline")
+ cleanup_started.update(
+ {
+ "eventType": "cleanup_started",
+ "scope": "cleanup",
+ "deliveryTaskId": "task-normal",
+ "data": {
+ "status": "started",
+ "resourceCount": 1,
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "stack-123",
+ "regionId": "cn-hangzhou",
+ "cleanupStatus": "started",
+ "progressStatus": "DELETE_STARTED",
+ },
+ }
+ )
+ journal = A2APipelineJournal(pipeline_dir)
+ journal.append(pipeline_started)
+ journal.append(cleanup_started)
+ A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pipeline_started]))
+ store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence)
+ service = A2APipelineRecoveryService(task_store=store)
+
+ state = await service.get_state(task_id="task-normal")
+
+ assert state["snapshot"]["lastSequence"] == 2
+ assert state["snapshot"]["cleanup"]["status"] == "started"
+ assert state["snapshot"]["cleanup"]["resources"][0]["resourceId"] == "stack-123"
+ assert "prompt" not in state["snapshot"]["cleanup"]
+ assert "ledgerPath" not in state["snapshot"]["cleanup"]
+
+
@pytest.mark.asyncio
async def test_recovery_rejects_context_id_that_does_not_match_task_id(tmp_path) -> None:
persistence = A2APersistenceStore(tmp_path / "a2a")
diff --git a/tests/a2a/test_pipeline_snapshot.py b/tests/a2a/test_pipeline_snapshot.py
index 508bfc32..30ff10f2 100644
--- a/tests/a2a/test_pipeline_snapshot.py
+++ b/tests/a2a/test_pipeline_snapshot.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
import logging
from pathlib import Path
@@ -49,10 +50,10 @@ def test_snapshot_load_logs_parse_failures(tmp_path, caplog) -> None:
def test_snapshot_save_cleans_temp_file_when_replace_fails(monkeypatch, tmp_path, caplog) -> None:
store = A2APipelineSnapshotStore(tmp_path)
- def fail_replace(self: Path, target: Path) -> Path:
- raise PermissionError(f"locked: {target}")
+ def fail_write(path: Path, value: dict, *, durable: bool = True) -> None:
+ raise PermissionError(f"locked: {path}")
- monkeypatch.setattr(Path, "replace", fail_replace)
+ monkeypatch.setattr(pipeline_snapshot, "atomic_write_json", fail_write)
caplog.set_level(logging.WARNING, logger="iac_code.a2a.pipeline_snapshot")
assert store.save({"status": "working"}) is False
@@ -90,6 +91,26 @@ def test_reduce_steps_and_pending_input() -> None:
assert snapshot["pendingInput"]["inputId"] == "input-confirm_and_select-1"
+def test_pipeline_warning_does_not_change_terminal_snapshot_status() -> None:
+ started = _base("evt-start", 1, "pipeline_started")
+ warning = _base("evt-warning", 2, "pipeline_warning", status="working")
+ warning["data"] = {"reason": "cleanup_tracking_unavailable"}
+
+ snapshot = reduce_pipeline_events([started, warning])
+
+ assert snapshot["status"] == "working"
+ assert snapshot["lastSequence"] == 2
+ assert snapshot.get("completedAt") is None
+ assert snapshot["control"]["warningHistory"] == [
+ {
+ "eventId": "evt-warning",
+ "sequence": 2,
+ "createdAt": "2026-06-08T10:00:00Z",
+ "data": {"reason": "cleanup_tracking_unavailable"},
+ }
+ ]
+
+
def test_reduce_input_received_completes_waiting_step() -> None:
step = _base("evt-1", 1, "step_started", scope="step")
step["step"] = {
@@ -114,6 +135,173 @@ def test_reduce_input_received_completes_waiting_step() -> None:
assert snapshot["steps"][0]["completedAt"] == "2026-06-08T10:00:00Z"
+def test_reduce_cleanup_handoff_updates_snapshot_cleanup() -> None:
+ handoff = _base("evt-cleanup-handoff", 1, "pipeline_handoff_ready", status="completed")
+ handoff["data"] = {
+ "action": "switch_to_normal",
+ "targetMode": "normal",
+ "outcome": "completed",
+ "summary": "[Pipeline Handoff Context]",
+ "cleanup": {
+ "status": "pending",
+ "resourceCount": 1,
+ "statusMessage": "检测到 1 个回滚残留资源,开始清理流程。",
+ "resources": [{"resourceId": "stack-123", "regionId": "cn-hangzhou"}],
+ },
+ }
+
+ snapshot = reduce_pipeline_events([handoff])
+
+ assert snapshot["cleanup"]["status"] == "pending"
+ assert snapshot["cleanup"]["resourceCount"] == 1
+ assert snapshot["cleanup"]["resources"] == [{"resourceId": "stack-123", "regionId": "cn-hangzhou"}]
+ assert snapshot["cleanup"]["history"][-1]["eventType"] == "pipeline_handoff_ready"
+ assert snapshot["normalHandoff"]["data"]["cleanup"]["resourceCount"] == 1
+
+
+def test_reduce_cleanup_progress_events_update_snapshot_cleanup() -> None:
+ started = _base("evt-cleanup-started", 1, "cleanup_started", scope="cleanup")
+ started["data"] = {
+ "status": "started",
+ "resourceCount": 1,
+ "resources": [{"resourceId": "stack-123", "regionId": "cn-hangzhou"}],
+ }
+ progress = _base("evt-cleanup-progress", 2, "cleanup_progress", scope="cleanup")
+ progress["data"] = {
+ "status": "in_progress",
+ "resourceId": "stack-123",
+ "regionId": "cn-hangzhou",
+ "stackStatus": "DELETE_IN_PROGRESS",
+ }
+ completed = _base("evt-cleanup-completed", 3, "cleanup_completed", scope="cleanup", status="completed")
+ completed["data"] = {
+ "status": "completed",
+ "resourceId": "stack-123",
+ "regionId": "cn-hangzhou",
+ "stackStatus": "DELETE_COMPLETE",
+ }
+
+ snapshot = reduce_pipeline_events([started, progress, completed])
+
+ assert snapshot["cleanup"]["status"] == "completed"
+ assert snapshot["cleanup"]["resourceCount"] == 1
+ assert snapshot["cleanup"]["resources"][0]["resourceId"] == "stack-123"
+ assert snapshot["cleanup"]["resources"][0]["stackStatus"] == "DELETE_COMPLETE"
+ assert [item["eventType"] for item in snapshot["cleanup"]["history"]] == [
+ "cleanup_started",
+ "cleanup_progress",
+ "cleanup_completed",
+ ]
+
+
+def test_reduce_cleanup_status_aggregates_multiple_resources() -> None:
+ started = _base("evt-cleanup-started", 1, "cleanup_started", scope="cleanup")
+ started["data"] = {
+ "status": "pending",
+ "resourceCount": 2,
+ "resources": [
+ {
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "stack-a",
+ "regionId": "cn-hangzhou",
+ "cleanupStatus": "pending",
+ },
+ {
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "stack-b",
+ "regionId": "cn-hangzhou",
+ "cleanupStatus": "pending",
+ },
+ ],
+ }
+ completed_one = _base("evt-cleanup-one-complete", 2, "cleanup_completed", scope="cleanup")
+ completed_one["data"] = {
+ "status": "completed",
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "stack-a",
+ "regionId": "cn-hangzhou",
+ "cleanupStatus": "completed",
+ "stackStatus": "DELETE_COMPLETE",
+ }
+ failed_one = _base("evt-cleanup-one-failed", 3, "cleanup_failed", scope="cleanup")
+ failed_one["data"] = {
+ "status": "failed",
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "stack-b",
+ "regionId": "cn-hangzhou",
+ "cleanupStatus": "failed",
+ "stackStatus": "DELETE_FAILED",
+ }
+
+ partial = reduce_pipeline_events([started, completed_one])
+ failed = reduce_pipeline_events([started, completed_one, failed_one])
+
+ assert partial["cleanup"]["status"] == "pending"
+ assert failed["cleanup"]["status"] == "failed"
+
+
+def test_reduce_cleanup_progress_distinguishes_provider_and_resource_type() -> None:
+ started = _base("evt-cleanup-started", 1, "cleanup_started", scope="cleanup")
+ started["data"] = {
+ "status": "started",
+ "resourceCount": 3,
+ "resources": [
+ {
+ "provider": "ros",
+ "resourceType": "stack",
+ "resourceId": "shared-id",
+ "regionId": "cn-hangzhou",
+ "stackStatus": "DELETE_IN_PROGRESS",
+ },
+ {
+ "provider": "ros",
+ "resourceType": "stack_set",
+ "resourceId": "shared-id",
+ "regionId": "cn-hangzhou",
+ "stackStatus": "DELETE_IN_PROGRESS",
+ },
+ {
+ "provider": "terraform",
+ "resourceType": "stack",
+ "resourceId": "shared-id",
+ "regionId": "cn-hangzhou",
+ "stackStatus": "DELETE_IN_PROGRESS",
+ },
+ ],
+ }
+ type_progress = _base("evt-cleanup-type-progress", 2, "cleanup_progress", scope="cleanup")
+ type_progress["data"] = {
+ "status": "in_progress",
+ "provider": "ros",
+ "resourceType": "stack_set",
+ "resourceId": "shared-id",
+ "regionId": "cn-hangzhou",
+ "stackStatus": "DELETE_COMPLETE",
+ }
+ provider_progress = _base("evt-cleanup-provider-progress", 3, "cleanup_progress", scope="cleanup")
+ provider_progress["data"] = {
+ "status": "in_progress",
+ "provider": "terraform",
+ "resourceType": "stack",
+ "resourceId": "shared-id",
+ "regionId": "cn-hangzhou",
+ "stackStatus": "DELETE_FAILED",
+ }
+
+ snapshot = reduce_pipeline_events([started, type_progress, provider_progress])
+
+ resources = {
+ (resource["provider"], resource["resourceType"]): resource for resource in snapshot["cleanup"]["resources"]
+ }
+ assert resources[("ros", "stack")]["stackStatus"] == "DELETE_IN_PROGRESS"
+ assert resources[("ros", "stack_set")]["stackStatus"] == "DELETE_COMPLETE"
+ assert resources[("terraform", "stack")]["stackStatus"] == "DELETE_FAILED"
+
+
def test_reduce_input_received_records_candidate_selection_details_on_step() -> None:
step = _base("evt-1", 1, "step_started", scope="step")
step["step"] = {
@@ -729,6 +917,7 @@ def test_reduce_stack_current_changed_updates_snapshot_stack_state() -> None:
"regionId": "cn-hangzhou",
"stackId": "stack-123",
"stackName": "demo",
+ "stackStatus": "DELETE_COMPLETE",
"isSuccess": True,
"current": False,
"cleared": True,
@@ -744,6 +933,40 @@ def test_reduce_stack_current_changed_updates_snapshot_stack_state() -> None:
assert [item["eventId"] for item in deleted_snapshot["stacks"]["history"]] == ["evt-create", "evt-delete"]
+def test_reduce_stack_current_changed_keeps_current_for_delete_requested() -> None:
+ created = _base("evt-create", 1, "stack_current_changed", scope="stack")
+ created["data"] = {
+ "toolName": "aliyun_api",
+ "toolUseId": "toolu-create",
+ "provider": "ros",
+ "action": "CreateStack",
+ "regionId": "cn-hangzhou",
+ "stackId": "stack-123",
+ "stackName": "demo",
+ "isSuccess": True,
+ "current": True,
+ }
+ delete_requested = _base("evt-delete-requested", 2, "stack_current_changed", scope="stack")
+ delete_requested["data"] = {
+ "toolName": "ros_stack",
+ "toolUseId": "toolu-delete",
+ "provider": "ros",
+ "action": "DeleteStack",
+ "regionId": "cn-hangzhou",
+ "stackId": "stack-123",
+ "stackName": "demo",
+ "stackStatus": "DELETE_REQUESTED",
+ "isSuccess": True,
+ "current": True,
+ }
+
+ snapshot = reduce_pipeline_events([created, delete_requested])
+
+ assert snapshot["stacks"]["current"]["stackId"] == "stack-123"
+ assert snapshot["stacks"]["byId"]["stack-123"]["current"] is True
+ assert snapshot["stacks"]["byId"]["stack-123"]["stackStatus"] == "DELETE_REQUESTED"
+
+
def test_reduce_artifact_created_prefers_top_level_artifact_metadata() -> None:
artifact = _base("evt-1", 1, "artifact_created", scope="step")
artifact["step"] = {"runId": "step-a-1", "id": "a", "index": 1, "total": 1, "attempt": 1}
@@ -940,6 +1163,81 @@ def test_store_sanitizes_non_finite_and_non_json_values(tmp_path) -> None:
assert loaded["display"]["candidateDetails"][0]["raw"].startswith(" None:
+ store = A2APipelineSnapshotStore(tmp_path / "pipeline")
+ raw_error = (
+ "DeleteStack failed AccessKeySecret=super-secret token=sk-live-1234567890 "
+ "at /Users/alice/.iac-code/projects/session/pipeline/cleanup.yaml"
+ )
+ snapshot = reduce_pipeline_events([_base("evt-1", 1, "pipeline_started")])
+ snapshot["pendingInput"] = {"prompt": "choose deployment target"}
+ snapshot["control"]["inputHistory"] = [{"prompt": "choose deployment target"}]
+ snapshot["control"]["handoffHistory"] = [
+ {
+ "data": {
+ "cleanup": {
+ "prompt": "hidden cleanup prompt",
+ "ledgerPath": "/tmp/cleanup.yaml",
+ "lastError": raw_error,
+ }
+ }
+ }
+ ]
+ snapshot["normalHandoff"] = {
+ "data": {
+ "cleanup": {
+ "prompt": "hidden cleanup prompt",
+ "ledgerPath": "/tmp/cleanup.yaml",
+ "lastError": raw_error,
+ }
+ }
+ }
+ snapshot["cleanup"] = {
+ "status": "pending",
+ "resourceCount": 1,
+ "resources": [{"resourceId": "stack-123", "lastError": raw_error}],
+ "history": [
+ {"data": {"prompt": "hidden cleanup prompt", "ledgerPath": "/tmp/cleanup.yaml", "lastError": raw_error}}
+ ],
+ "prompt": "hidden cleanup prompt",
+ "ledgerPath": "/tmp/cleanup.yaml",
+ "last_error": raw_error,
+ }
+
+ store.save(snapshot)
+
+ loaded = store.load()
+ assert loaded is not None
+ assert loaded["pendingInput"]["prompt"] == "choose deployment target"
+ assert loaded["control"]["inputHistory"][0]["prompt"] == "choose deployment target"
+ assert "prompt" not in loaded["control"]["handoffHistory"][0]["data"]["cleanup"]
+ assert raw_error not in loaded["control"]["handoffHistory"][0]["data"]["cleanup"]["lastError"]
+ assert "ledgerPath" not in loaded["normalHandoff"]["data"]["cleanup"]
+ assert raw_error not in loaded["normalHandoff"]["data"]["cleanup"]["lastError"]
+ assert "prompt" not in loaded["cleanup"]
+ assert raw_error not in loaded["cleanup"]["last_error"]
+ assert raw_error not in loaded["cleanup"]["resources"][0]["lastError"]
+ assert "ledgerPath" not in loaded["cleanup"]["history"][0]["data"]
+ assert raw_error not in loaded["cleanup"]["history"][0]["data"]["lastError"]
+ rendered = json.dumps(loaded, ensure_ascii=False)
+ assert "super-secret" not in rendered
+ assert "sk-live-1234567890" not in rendered
+ assert "/Users/alice" not in rendered
+ assert "[REDACTED]" in rendered
+ assert "[PATH]" in rendered
+
+ store.path.write_text(json.dumps(snapshot), encoding="utf-8")
+ loaded = store.load()
+ assert loaded is not None
+ assert loaded["pendingInput"]["prompt"] == "choose deployment target"
+ assert "prompt" not in loaded["normalHandoff"]["data"]["cleanup"]
+ assert "ledgerPath" not in loaded["cleanup"]
+ rendered = json.dumps(loaded, ensure_ascii=False)
+ assert "super-secret" not in rendered
+ assert "sk-live-1234567890" not in rendered
+ assert "/Users/alice" not in rendered
+
+
def test_store_returns_none_for_invalid_utf8_snapshot(tmp_path) -> None:
store = A2APipelineSnapshotStore(tmp_path / "pipeline")
store.pipeline_dir.mkdir(parents=True)
diff --git a/tests/a2a/test_pipeline_stream.py b/tests/a2a/test_pipeline_stream.py
index ca1d1f65..3a14f93c 100644
--- a/tests/a2a/test_pipeline_stream.py
+++ b/tests/a2a/test_pipeline_stream.py
@@ -15,7 +15,7 @@
from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator
from iac_code.a2a.pipeline_journal import A2APipelineJournal
from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore
-from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher
+from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher, is_recovery_semantic_event
from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType
from iac_code.types.stream_events import (
AskUserQuestionEvent,
@@ -81,6 +81,17 @@ def _envelope(event_type: str, status: str = "working") -> dict[str, Any]:
}
+def test_pipeline_warning_is_recovery_semantic() -> None:
+ assert is_recovery_semantic_event(_envelope("pipeline_warning")) is True
+
+
+def test_unknown_working_step_event_is_recovery_semantic() -> None:
+ envelope = _envelope("custom_step_progress")
+ envelope["scope"] = "step"
+
+ assert is_recovery_semantic_event(envelope) is True
+
+
@pytest.mark.asyncio
async def test_publish_text_writes_a2a_metadata_journal_and_snapshot(tmp_path: Path) -> None:
publisher, queue = _publisher(tmp_path)
@@ -300,7 +311,7 @@ async def test_publish_permission_denies_future_when_permission_metadata_is_not_
publisher, queue = _publisher(tmp_path)
future: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
- def fail_append(_event: dict[str, Any]) -> None:
+ def fail_append(_event: dict[str, Any], durable: bool = False) -> None:
raise OSError("append failed")
def fail_save(_snapshot: dict[str, Any]) -> bool:
@@ -324,6 +335,91 @@ def fail_save(_snapshot: dict[str, Any]) -> bool:
assert queue.events == []
+@pytest.mark.asyncio
+async def test_recovery_semantic_event_is_not_enqueued_when_metadata_persistence_fails(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ publisher, queue = _publisher(tmp_path)
+
+ def fail_append(_event: dict[str, Any], durable: bool = False) -> None:
+ raise OSError("journal locked")
+
+ monkeypatch.setattr(publisher.journal, "append", fail_append)
+ monkeypatch.setattr(publisher.snapshot_store, "save", lambda _snapshot: False)
+
+ result = await publisher.publish_manual("pipeline_started", "pipeline")
+
+ assert result is None
+ assert queue.events == []
+
+
+@pytest.mark.asyncio
+async def test_text_delta_can_be_enqueued_when_only_durable_metadata_fails(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ publisher, queue = _publisher(tmp_path)
+
+ def fail_append(event: dict[str, Any], durable: bool = False) -> None:
+ if durable:
+ raise OSError("journal locked")
+ A2APipelineJournal.append(publisher.journal, event)
+
+ monkeypatch.setattr(publisher.journal, "append", fail_append)
+ monkeypatch.setattr(publisher.snapshot_store, "save", lambda _snapshot: False)
+
+ returned = await publisher.publish(TextDeltaEvent(text="hello"))
+
+ assert returned == "hello"
+ assert len(queue.events) == 1
+
+
+@pytest.mark.asyncio
+async def test_manual_recovery_event_routes_durable_metadata_without_explicit_request(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ publisher, _queue = _publisher(tmp_path)
+ durable_flags: list[bool] = []
+
+ def record_append(event: dict[str, Any], durable: bool = False) -> None:
+ durable_flags.append(durable)
+ A2APipelineJournal.append(publisher.journal, event)
+
+ monkeypatch.setattr(publisher.journal, "append", record_append)
+
+ await publisher.publish_manual("pipeline_started", "pipeline")
+
+ assert durable_flags == [True]
+
+
+@pytest.mark.asyncio
+async def test_translated_recovery_event_routes_durable_metadata_without_explicit_request(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ publisher, _queue = _publisher(tmp_path)
+ durable_flags: list[bool] = []
+
+ def record_append(event: dict[str, Any], durable: bool = False) -> None:
+ durable_flags.append(durable)
+ A2APipelineJournal.append(publisher.journal, event)
+
+ monkeypatch.setattr(publisher.journal, "append", record_append)
+
+ await publisher.publish(
+ PipelineEvent(
+ type=PipelineEventType.STEP_STARTED,
+ step_id="confirm_and_select",
+ timestamp=1717821600.0,
+ data={"index": 1, "total": 2},
+ )
+ )
+
+ assert durable_flags == [True]
+
+
@pytest.mark.asyncio
async def test_publish_permission_redacts_and_truncates_tool_input_in_status_metadata_and_journal(
tmp_path: Path,
@@ -702,7 +798,7 @@ async def test_publish_does_not_emit_artifact_update_when_artifact_metadata_is_n
store = A2AArtifactStore(tmp_path / "artifacts")
publisher, queue = _publisher(tmp_path, artifact_store=store, exposure_types=[A2AExposureType.TOOL_TRACE])
- def fail_append(_event: dict[str, Any]) -> None:
+ def fail_append(_event: dict[str, Any], durable: bool = False) -> None:
raise OSError("append failed")
def fail_save(_snapshot: dict[str, Any]) -> None:
@@ -896,7 +992,7 @@ async def test_publish_candidate_failure_keeps_a2a_task_working(tmp_path: Path)
async def test_publish_continues_when_pipeline_persistence_fails(tmp_path: Path) -> None:
publisher, queue = _publisher(tmp_path)
- def fail_append(_event: dict[str, Any]) -> None:
+ def fail_append(_event: dict[str, Any], durable: bool = False) -> None:
raise OSError("disk full")
publisher.journal.append = fail_append # type: ignore[method-assign]
@@ -984,7 +1080,7 @@ async def test_publish_rebuilds_missing_snapshot_with_current_event_when_journal
await publisher.publish(TextDeltaEvent(text="old"))
publisher.snapshot_store.path.unlink()
- def fail_append(_event: dict[str, Any]) -> None:
+ def fail_append(_event: dict[str, Any], durable: bool = False) -> None:
raise OSError("disk full")
publisher.journal.append = fail_append # type: ignore[method-assign]
@@ -1115,6 +1211,25 @@ async def test_publish_ask_user_question_maps_to_input_required_snapshot(tmp_pat
assert snapshot["pendingInput"]["question"] == "请选择部署目标"
+@pytest.mark.asyncio
+async def test_pipeline_input_received_is_not_enqueued_when_metadata_persistence_fails(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ publisher, queue = _publisher(tmp_path)
+
+ def fail_append(_event: dict[str, Any], durable: bool = False) -> None:
+ raise OSError("journal locked")
+
+ monkeypatch.setattr(publisher.journal, "append", fail_append)
+ monkeypatch.setattr(publisher.snapshot_store, "save", lambda _snapshot: False)
+
+ result = await publisher.publish_manual("input_received", "pipeline")
+
+ assert result is None
+ assert queue.events == []
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize(
("failed", "expected_state"),
diff --git a/tests/a2a/test_selling_console_frontend.py b/tests/a2a/test_selling_console_frontend.py
new file mode 100644
index 00000000..b175b305
--- /dev/null
+++ b/tests/a2a/test_selling_console_frontend.py
@@ -0,0 +1,4893 @@
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+
+import pytest
+
+APP_JS = Path(__file__).resolve().parents[2] / "scripts" / "a2a" / "selling_console_web" / "app.js"
+STYLES_CSS = APP_JS.parent / "styles.css"
+NODE_RELATIVE_PATH = Path(".cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node")
+
+
+def bundled_node_candidates() -> list[Path]:
+ override = os.environ.get("IAC_CODE_TEST_NODE")
+ if override:
+ return [Path(override).expanduser()]
+ candidates = [Path.home() / NODE_RELATIVE_PATH]
+ home_env = os.environ.get("HOME")
+ if home_env:
+ candidates.append(Path(home_env).expanduser() / NODE_RELATIVE_PATH)
+ candidates.extend(parent / NODE_RELATIVE_PATH for parent in APP_JS.parents)
+ return candidates
+
+
+def node_command() -> list[str]:
+ node = shutil.which("node")
+ if node:
+ return [node]
+ for fallback in bundled_node_candidates():
+ if fallback.exists():
+ return [str(fallback)]
+ pytest.skip("node is not installed")
+
+
+def run_node_script(source: str) -> dict:
+ with tempfile.TemporaryDirectory(prefix="iac-code-selling-console-test-") as temp_dir:
+ script_path = Path(temp_dir) / "script.js"
+ script_path.write_text(source, encoding="utf-8")
+ result = subprocess.run(
+ [*node_command(), str(script_path)],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+def test_run_node_script_uses_file_instead_of_inline_eval(monkeypatch: pytest.MonkeyPatch) -> None:
+ source = 'console.log(JSON.stringify({"ok": true}));'
+ command_seen: list[str] = []
+
+ def fake_run(command, *, capture_output, text, check, encoding):
+ command_seen.extend(str(part) for part in command)
+ assert capture_output is True
+ assert text is True
+ assert check is False
+ assert encoding == "utf-8"
+ assert "-e" not in command_seen
+ script_path = Path(command_seen[-1])
+ assert script_path.read_text(encoding="utf-8") == source
+ return subprocess.CompletedProcess(command, 0, stdout='{"ok": true}\n', stderr="")
+
+ monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
+ monkeypatch.setattr(subprocess, "run", fake_run)
+
+ assert run_node_script(source) == {"ok": True}
+ assert command_seen[:1] == ["/usr/bin/node"]
+
+
+def test_node_command_falls_back_to_home_bundled_node_when_path_is_empty(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ fake_node = tmp_path / NODE_RELATIVE_PATH
+ fake_node.parent.mkdir(parents=True)
+ fake_node.write_text("#!/bin/sh\n", encoding="utf-8")
+ fake_node.chmod(0o755)
+ monkeypatch.setenv("PATH", "")
+ monkeypatch.delenv("IAC_CODE_TEST_NODE", raising=False)
+ monkeypatch.setenv("HOME", str(tmp_path))
+ assert shutil.which("node") is None
+
+ command = node_command()
+
+ assert command == [str(fake_node)]
+ assert Path(command[0]).exists()
+
+
+def test_node_command_uses_env_override_when_path_is_empty(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ fake_node = tmp_path / "node"
+ fake_node.write_text("#!/bin/sh\n", encoding="utf-8")
+ fake_node.chmod(0o755)
+ monkeypatch.setenv("PATH", "")
+ monkeypatch.setenv("IAC_CODE_TEST_NODE", str(fake_node))
+
+ command = node_command()
+
+ assert command == [str(fake_node)]
+
+
+def reducer_harness(expression: str) -> dict:
+ app_source = APP_JS.read_text(encoding="utf-8")
+ script = f"""
+const assert = require("assert");
+global.window = {{}};
+global.document = {{
+ readyState: "loading",
+ addEventListener() {{}},
+ querySelector() {{ return null; }},
+ querySelectorAll() {{ return []; }},
+ getElementById() {{ return null; }}
+}};
+{app_source}
+const reducers = window.SellingConsoleReducers;
+const output = (() => {{
+ {expression}
+}})();
+console.log(JSON.stringify(output));
+"""
+ return run_node_script(script)
+
+
+def controller_harness(expression: str) -> dict:
+ app_source = APP_JS.read_text(encoding="utf-8")
+ script = f"""
+class FakeElement {{
+ constructor(tagName, id = "") {{
+ this.tagName = tagName.toUpperCase();
+ this.id = id;
+ this.children = [];
+ this.attributes = {{}};
+ this.listeners = {{}};
+ this.className = "";
+ this.textContent = "";
+ this.value = "";
+ this.hidden = false;
+ this.scrollTop = 0;
+ this.scrollHeight = 100;
+ this.clientHeight = 30;
+ }}
+ appendChild(child) {{
+ this.children.push(child);
+ return child;
+ }}
+ replaceChildren(...children) {{
+ this.children = children;
+ this.textContent = "";
+ }}
+ setAttribute(name, value) {{
+ this.attributes[name] = String(value);
+ }}
+ getAttribute(name) {{
+ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
+ }}
+ addEventListener(name, listener) {{
+ this.listeners[name] = this.listeners[name] || [];
+ this.listeners[name].push(listener);
+ }}
+ click() {{
+ (this.listeners.click || []).forEach((listener) => listener({{type: "click"}}));
+ }}
+}}
+function walk(element, callback) {{
+ if (!element) {{
+ return;
+ }}
+ callback(element);
+ (element.children || []).forEach((child) => walk(child, callback));
+}}
+function textOf(element) {{
+ if (!element) {{
+ return "";
+ }}
+ return [element.textContent || "", ...(element.children || []).map(textOf)].join("");
+}}
+const elements = {{
+ "step-list": new FakeElement("div", "step-list"),
+ "composer-progress": new FakeElement("div", "composer-progress"),
+ "debug-drawer": new FakeElement("details", "debug-drawer"),
+ "progress-debug-panel": new FakeElement("div", "progress-debug-panel"),
+ "debug-output": new FakeElement("pre", "debug-output"),
+ "debug-session-info": new FakeElement("div", "debug-session-info"),
+ "normal-handoff-notice": new FakeElement("div", "normal-handoff-notice"),
+ "plans-grid": new FakeElement("div", "plans-grid"),
+ "status-pill": new FakeElement("span", "status-pill"),
+ "status-alert": new FakeElement("div", "status-alert"),
+ "server-url": new FakeElement("input", "server-url"),
+ cwd: new FakeElement("input", "cwd"),
+ "composer-input": new FakeElement("textarea", "composer-input"),
+ "send-button": new FakeElement("button", "send-button"),
+ "health-button": new FakeElement("button", "health-button"),
+ "fetch-state-button": new FakeElement("button", "fetch-state-button"),
+ "cancel-button": new FakeElement("button", "cancel-button"),
+}};
+elements["normal-handoff-notice"].hidden = true;
+const debugPre = elements["debug-output"];
+const roots = Object.values(elements);
+global.window = {{SELLING_CONSOLE_DEFAULTS: {{serverUrl: "http://127.0.0.1:41299", cwd: "/workspace"}}}};
+global.document = {{
+ readyState: "loading",
+ addEventListener() {{}},
+ createElement(tagName) {{ return new FakeElement(tagName); }},
+ getElementById(id) {{ return elements[id] || null; }},
+ querySelector(selector) {{
+ if (selector === "#debug-drawer pre") {{
+ return debugPre;
+ }}
+ if (selector.startsWith("#")) {{
+ return elements[selector.slice(1)] || null;
+ }}
+ return null;
+ }},
+ querySelectorAll(selector) {{
+ const matches = [];
+ roots.forEach((root) => walk(root, (element) => {{
+ if (selector === "[data-step-id]" && element.getAttribute("data-step-id") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-step-event-kind]" && element.getAttribute("data-step-event-kind") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-step-state-icon]" && element.getAttribute("data-step-state-icon") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-step-toggle]" && element.getAttribute("data-step-toggle") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-step-result-field]" && element.getAttribute("data-step-result-field") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-step-result-option]" && element.getAttribute("data-step-result-option") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-step-candidate-result]" && element.getAttribute("data-step-candidate-result") !== null) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-step-candidate-result-summary]" &&
+ element.getAttribute("data-step-candidate-result-summary") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-step-candidate-result-process]" &&
+ element.getAttribute("data-step-candidate-result-process") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-step-candidate-progress]" &&
+ element.getAttribute("data-step-candidate-progress") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-step-candidate-progress-head]" &&
+ element.getAttribute("data-step-candidate-progress-head") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-pending-input-kind]" && element.getAttribute("data-pending-input-kind") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-pending-input-option]" && element.getAttribute("data-pending-input-option") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-progress-step]" && element.getAttribute("data-progress-step") !== null) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-progress-variant-option]" &&
+ element.getAttribute("data-progress-variant-option") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-progress-param]" && element.getAttribute("data-progress-param") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-progress-param-group]" && element.getAttribute("data-progress-param-group") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-progress-step-option]" && element.getAttribute("data-progress-step-option") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-candidate-choice]" && element.getAttribute("data-candidate-choice") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-candidate-index]" && element.getAttribute("data-candidate-index") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-candidate-status]" && element.getAttribute("data-candidate-status") !== null) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-candidate-subpipeline]" &&
+ element.getAttribute("data-candidate-subpipeline") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-candidate-subpipeline-body]" &&
+ element.getAttribute("data-candidate-subpipeline-body") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-candidate-subpipeline-event]" &&
+ element.getAttribute("data-candidate-subpipeline-event") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-candidate-subpipeline-toggle]" &&
+ element.getAttribute("data-candidate-subpipeline-toggle") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-candidate-substep]" &&
+ element.getAttribute("data-candidate-substep") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-step-process]" && element.getAttribute("data-step-process") !== null) {{
+ matches.push(element);
+ }}
+ if (selector === "[data-step-event-list]" && element.getAttribute("data-step-event-list") !== null) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-step-process-event]" &&
+ element.getAttribute("data-step-process-event") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-debug-session-field]" &&
+ element.getAttribute("data-debug-session-field") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-normal-handoff-message]" &&
+ element.getAttribute("data-normal-handoff-message") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-chat-message]" &&
+ element.getAttribute("data-chat-message") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-chat-avatar]" &&
+ element.getAttribute("data-chat-avatar") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-normal-turn]" &&
+ element.getAttribute("data-normal-turn") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-normal-process]" &&
+ element.getAttribute("data-normal-process") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-normal-process-event]" &&
+ element.getAttribute("data-normal-process-event") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-normal-answer]" &&
+ element.getAttribute("data-normal-answer") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-markdown-node]" &&
+ element.getAttribute("data-markdown-node") !== null
+ ) {{
+ matches.push(element);
+ }}
+ if (
+ selector === "[data-template-popover]" &&
+ element.getAttribute("data-template-popover") !== null
+ ) {{
+ matches.push(element);
+ }}
+ }}));
+ return matches;
+ }},
+}};
+{app_source}
+(async () => {{
+ const output = await (async () => {{
+ const controller = window.SellingConsoleController;
+ const debug = window.SellingConsoleDebug;
+ const reducers = window.SellingConsoleReducers;
+ const elementById = (id) => elements[id];
+ const all = (selector) => document.querySelectorAll(selector);
+ const text = textOf;
+ const debugText = () => debugPre.textContent;
+ {expression}
+ }})();
+ console.log(JSON.stringify(output));
+}})().catch((error) => {{
+ console.error(error && error.stack ? error.stack : String(error));
+ process.exit(1);
+}});
+"""
+ return run_node_script(script)
+
+
+def test_reducer_maps_pipeline_steps_to_console_sections() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({serverUrl: "http://127.0.0.1:41299", cwd: "/workspace"});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ status: "working",
+ taskId: "task-1",
+ contextId: "ctx-1",
+ sequence: 3,
+ step: {id: "architecture_planning", name: "架构规划", status: "completed"}
+ }}}
+});
+return {
+ taskId: next.pipelineTaskId,
+ contextId: next.contextId,
+ sequence: next.lastSequence,
+ architectureStatus: next.steps.architecture_planning.status
+};
+"""
+ )
+
+ assert output == {
+ "taskId": "task-1",
+ "contextId": "ctx-1",
+ "sequence": 3,
+ "architectureStatus": "completed",
+ }
+
+
+def test_reducer_uses_event_type_to_mark_completed_step_when_envelope_status_is_working() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ status: "working",
+ step: {id: "intent_parsing"},
+ data: {
+ conclusion: {
+ scenario: "Nginx 静态站点",
+ region: "华东 1(杭州)",
+ budget: "低成本"
+ }
+ }
+ }}}
+});
+return {
+ status: next.steps.intent_parsing.status,
+ eventCount: next.steps.intent_parsing.events.length
+};
+"""
+ )
+
+ assert output == {
+ "status": "completed",
+ "eventCount": 1,
+ }
+
+
+def test_reducer_keeps_parent_step_working_when_candidate_sub_step_completes() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+state.steps.evaluate_candidates.status = "working";
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "candidate_step_completed",
+ status: "working",
+ step: {id: "evaluate_candidates"},
+ candidate: {index: 0},
+ candidateStep: {id: "cost_estimating"},
+ data: {summary: "候选方案费用已估算"}
+ }}}
+});
+return {
+ status: next.steps.evaluate_candidates.status,
+ eventCount: next.steps.evaluate_candidates.events.length
+};
+"""
+ )
+
+ assert output == {
+ "status": "working",
+ "eventCount": 1,
+ }
+
+
+def test_reducer_collects_candidate_details_from_tool_display() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState();
+const next = reducers.reducePipelinePayload(state, {
+ snapshot: {
+ status: "waiting_input",
+ display: {
+ candidateDetails: [{
+ candidateName: "ECS 经典网络方案",
+ candidateIndex: 0,
+ summary: "VPC + ECS + EIP",
+ totalMonthlyCost: "¥33.89/月",
+ costItems: [{name: "ECS", spec: "1vCPU/1GiB", monthly_cost: "¥33.89/月"}]
+ }]
+ },
+ pendingInput: {
+ kind: "ask_user_question",
+ prompt: "请选择方案",
+ options: [{id: "0", label: "ECS 经典网络方案"}]
+ }
+ }
+});
+return {
+ candidateCount: next.candidates.length,
+ candidateName: next.candidates[0].name,
+ candidateCost: next.candidates[0].totalMonthlyCost,
+ pendingPrompt: next.pendingInput.prompt
+};
+"""
+ )
+
+ assert output == {
+ "candidateCount": 1,
+ "candidateName": "ECS 经典网络方案",
+ "candidateCost": "¥33.89/月",
+ "pendingPrompt": "请选择方案",
+ }
+
+
+def test_reducer_preserves_zero_candidate_total_monthly_cost() -> None:
+ output = reducer_harness(
+ """
+const next = reducers.reducePipelinePayload(reducers.createInitialState({}), {
+ snapshot: {
+ display: {
+ candidateDetails: [{
+ candidateName: "免费方案",
+ candidateIndex: 0,
+ totalMonthlyCost: 0
+ }]
+ }
+ }
+});
+return {
+ totalMonthlyCost: next.candidates[0].totalMonthlyCost
+};
+"""
+ )
+
+ assert output == {"totalMonthlyCost": 0}
+
+
+def test_reducer_collects_candidate_details_from_detail_wrapper() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+const next = reducers.reducePipelinePayload(state, {
+ snapshot: {
+ status: "waiting_input",
+ display: {
+ candidateDetails: [{
+ detailId: "detail-1",
+ candidate: {index: 0},
+ step: {id: "confirm_and_select"},
+ detail: {
+ candidateName: "低成本 ECS 方案",
+ summary: "single ecs",
+ totalMonthlyCost: "CNY 60",
+ costItems: [{name: "ecs", monthly_cost: "CNY 60"}]
+ }
+ }]
+ }
+ }
+});
+return {
+ candidateCount: next.candidates.length,
+ firstName: next.candidates[0].name,
+ firstIndex: next.candidates[0].candidateIndex,
+ firstSummary: next.candidates[0].summary,
+ firstCost: next.candidates[0].totalMonthlyCost,
+ firstCostItemName: next.candidates[0].costItems[0].name
+};
+"""
+ )
+
+ assert output == {
+ "candidateCount": 1,
+ "firstName": "低成本 ECS 方案",
+ "firstIndex": 0,
+ "firstSummary": "single ecs",
+ "firstCost": "CNY 60",
+ "firstCostItemName": "ecs",
+ }
+
+
+def test_reducer_collects_candidate_options_from_complete_step_conclusion() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ step: {id: "complete_step", status: "completed"},
+ data: {
+ conclusion: {
+ options: [{
+ title: "轻量应用服务器一体化方案",
+ index: 1,
+ summary: "开箱即用,管理简单。",
+ totalMonthlyCost: "¥0/月"
+ }]
+ }
+ }
+ }}}
+});
+return {
+ count: next.candidates.length,
+ name: next.candidates[0] && next.candidates[0].name,
+ index: next.candidates[0] && next.candidates[0].candidateIndex,
+ cost: next.candidates[0] && next.candidates[0].totalMonthlyCost
+};
+"""
+ )
+
+ assert output == {
+ "count": 1,
+ "name": "轻量应用服务器一体化方案",
+ "index": 1,
+ "cost": "¥0/月",
+ }
+
+
+def test_reducer_populates_candidate_summary_and_price_from_nested_candidate_payload() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ step: {id: "architecture_planning", status: "completed"},
+ data: {
+ conclusion: {
+ candidates: [{
+ index: 0,
+ template: "创建基础 VPC 专有网络",
+ candidate: {
+ output_path: "templates/1-basic-vpc.yml",
+ pros: "满足基础网络隔离需求、零成本、可按需扩展子网和安全组",
+ cons: "仅含 VPC,需后续手动添加 VSwitch",
+ monthly_estimate: 0
+ },
+ cost: {
+ monthly_estimate: "¥0/月",
+ currency: "CNY"
+ }
+ }]
+ }
+ }
+ }}}
+});
+return {
+ count: next.candidates.length,
+ name: next.candidates[0].name,
+ summary: next.candidates[0].summary,
+ totalMonthlyCost: next.candidates[0].totalMonthlyCost,
+ outputPath: next.candidates[0].outputPath
+};
+"""
+ )
+
+ assert output == {
+ "count": 1,
+ "name": "创建基础 VPC 专有网络",
+ "summary": "满足基础网络隔离需求、零成本、可按需扩展子网和安全组",
+ "totalMonthlyCost": "¥0/月",
+ "outputPath": "templates/1-basic-vpc.yml",
+ }
+
+
+def test_reducer_collects_step_two_draft_candidates_from_architecture_completion() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ step: {id: "architecture_planning", status: "completed"},
+ data: {
+ conclusion: {
+ draft_candidates: [{
+ candidate_index: 0,
+ candidate_name: "基础 VPC 网络",
+ first_version_description: "创建一个基础 VPC,作为后续云资源的网络容器。",
+ rough_monthly_estimate: "¥0/月"
+ }]
+ }
+ }
+ }}}
+});
+return {
+ count: next.candidates.length,
+ name: next.candidates[0] && next.candidates[0].name,
+ summary: next.candidates[0] && next.candidates[0].summary,
+ cost: next.candidates[0] && next.candidates[0].totalMonthlyCost
+};
+"""
+ )
+
+ assert output == {
+ "count": 1,
+ "name": "基础 VPC 网络",
+ "summary": "创建一个基础 VPC,作为后续云资源的网络容器。",
+ "cost": "¥0/月",
+ }
+
+
+def test_reducer_updates_candidate_summary_and_price_from_candidate_completed_event() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+state.candidates = [{candidateIndex: 0, name: "基础 VPC 网络"}];
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "candidate_completed",
+ status: "working",
+ step: {id: "evaluate_candidates"},
+ candidate: {index: 0},
+ data: {
+ candidate_name: "基础 VPC 网络",
+ summary: "VPC 本身免费,适合作为后续子网和云资源的基础容器。",
+ total_monthly_cost: "¥0/月"
+ }
+ }}}
+});
+return {
+ count: next.candidates.length,
+ name: next.candidates[0].name,
+ summary: next.candidates[0].summary,
+ cost: next.candidates[0].totalMonthlyCost,
+ subEventKind: next.candidates[0].subEvents[0].eventType
+};
+"""
+ )
+
+ assert output == {
+ "count": 1,
+ "name": "基础 VPC 网络",
+ "summary": "VPC 本身免费,适合作为后续子网和云资源的基础容器。",
+ "cost": "¥0/月",
+ "subEventKind": "candidate_completed",
+ }
+
+
+def test_reducer_updates_candidate_from_nested_candidate_completed_conclusions() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+state.candidates = [{
+ candidateIndex: 0,
+ name: "经济型演示方案",
+ summary: "成本最低,适合个人演示场景",
+ totalMonthlyCost: "¥50 - ¥80"
+}];
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "candidate_completed",
+ status: "working",
+ step: {id: "evaluate_candidates"},
+ candidate: {index: 0, name: "经济型演示方案"},
+ data: {
+ candidateIndex: 0,
+ candidateName: "经济型演示方案",
+ conclusions: {
+ template: {
+ file_path: "templates/1-economy-nginx.yml",
+ description: "经济型 Nginx 演示环境 - VPC 内单可用区部署一台 ECS。"
+ },
+ cost: {
+ monthly_estimate: "¥74/月",
+ resources: [
+ {type: "ECS 实例", cost: "¥34/月"},
+ {type: "系统盘", cost: "¥40/月"}
+ ]
+ }
+ }
+ }
+ }}}
+});
+return {
+ count: next.candidates.length,
+ name: next.candidates[0].name,
+ summary: next.candidates[0].summary,
+ cost: next.candidates[0].totalMonthlyCost,
+ outputPath: next.candidates[0].outputPath,
+ costItemCount: next.candidates[0].costItems.length
+};
+"""
+ )
+
+ assert output == {
+ "count": 1,
+ "name": "经济型演示方案",
+ "summary": "经济型 Nginx 演示环境 - VPC 内单可用区部署一台 ECS。",
+ "cost": "¥74/月",
+ "outputPath": "templates/1-economy-nginx.yml",
+ "costItemCount": 2,
+ }
+
+
+def test_reducer_collects_snake_case_candidate_index_from_conclusion_options() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ step: {id: "complete_step", status: "completed"},
+ data: {
+ conclusion: {
+ options: [{
+ title: "低成本 ECS 方案",
+ candidate_index: 3,
+ total_monthly_cost: "¥33.89/月"
+ }]
+ }
+ }
+ }}}
+});
+reducers.selectCandidate(next, next.candidates[0].candidateIndex);
+return {
+ count: next.candidates.length,
+ index: next.candidates[0].candidateIndex,
+ cost: next.candidates[0].totalMonthlyCost,
+ prompt: reducers.promptForSelectedCandidate(next)
+};
+"""
+ )
+
+ assert output == {
+ "count": 1,
+ "index": 3,
+ "cost": "¥33.89/月",
+ "prompt": "选择方案3",
+ }
+
+
+def test_reducer_does_not_mutate_original_state() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({serverUrl: "http://server", cwd: "/workspace"});
+const originalStep = state.steps.architecture_planning;
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ status: "working",
+ taskId: "task-1",
+ contextId: "ctx-1",
+ sequence: 1,
+ step: {id: "architecture_planning", status: "completed"}
+ }}}
+});
+return {
+ sameState: next === state,
+ sameSteps: next.steps === state.steps,
+ sameStep: next.steps.architecture_planning === originalStep,
+ originalTaskId: state.pipelineTaskId,
+ originalStepStatus: state.steps.architecture_planning.status,
+ originalEventCount: state.steps.architecture_planning.events.length,
+ nextTaskId: next.pipelineTaskId,
+ nextStepStatus: next.steps.architecture_planning.status,
+ nextEventCount: next.steps.architecture_planning.events.length
+};
+"""
+ )
+
+ assert output == {
+ "sameState": False,
+ "sameSteps": False,
+ "sameStep": False,
+ "originalTaskId": "",
+ "originalStepStatus": "pending",
+ "originalEventCount": 0,
+ "nextTaskId": "task-1",
+ "nextStepStatus": "completed",
+ "nextEventCount": 1,
+ }
+
+
+def test_reducer_collects_realtime_candidate_detail_event() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "candidate_detail_shown",
+ status: "working",
+ taskId: "task-1",
+ contextId: "ctx-1",
+ sequence: 7,
+ step: {id: "confirm_and_select", status: "working"},
+ candidate: {index: 0},
+ data: {
+ detailId: "detail-1",
+ detail: {
+ candidateName: "低成本 ECS 方案",
+ summary: "single ecs",
+ totalMonthlyCost: "CNY 60",
+ costItems: [{name: "ecs", monthly_cost: "CNY 60"}]
+ }
+ }
+ }}}
+});
+return {
+ count: next.candidates.length,
+ name: next.candidates[0].name,
+ index: next.candidates[0].candidateIndex,
+ cost: next.candidates[0].totalMonthlyCost,
+ eventCount: next.steps.confirm_and_select.events.length
+};
+"""
+ )
+
+ assert output == {
+ "count": 1,
+ "name": "低成本 ECS 方案",
+ "index": 0,
+ "cost": "CNY 60",
+ "eventCount": 1,
+ }
+
+
+def test_reducer_does_not_retain_mutable_candidate_event_payload_references() -> None:
+ output = reducer_harness(
+ """
+const costItems = [{name: "ecs"}];
+const payload = {metadata: {iac_code: {pipeline: {
+ eventType: "candidate_detail_shown",
+ status: "working",
+ taskId: "task-1",
+ contextId: "ctx-1",
+ sequence: 7,
+ step: {id: "confirm_and_select", status: "working"},
+ candidate: {index: 0},
+ data: {
+ detailId: "detail-1",
+ detail: {
+ candidateName: "低成本 ECS 方案",
+ totalMonthlyCost: "CNY 60",
+ costItems
+ }
+ }
+}}}};
+const next = reducers.reducePipelinePayload(reducers.createInitialState({}), payload);
+costItems[0].name = "mutated";
+payload.metadata.iac_code.pipeline.data.detail.candidateName = "被污染";
+return {
+ eventName: next.steps.confirm_and_select.events[0].data.detail.candidateName,
+ eventCostItemName: next.steps.confirm_and_select.events[0].data.detail.costItems[0].name,
+ candidateName: next.candidates[0].name,
+ candidateCostItemName: next.candidates[0].costItems[0].name
+};
+"""
+ )
+
+ assert output == {
+ "eventName": "低成本 ECS 方案",
+ "eventCostItemName": "ecs",
+ "candidateName": "低成本 ECS 方案",
+ "candidateCostItemName": "ecs",
+ }
+
+
+def test_reducer_clones_existing_step_events_when_cloning_state() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+state.steps.confirm_and_select.events.push({
+ eventType: "candidate_detail_shown",
+ data: {detail: {candidateName: "旧事件", costItems: [{name: "ecs"}]}}
+});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ step: {id: "architecture_planning", status: "completed"}
+ }}}
+});
+state.steps.confirm_and_select.events[0].data.detail.candidateName = "mutated";
+state.steps.confirm_and_select.events[0].data.detail.costItems[0].name = "mutated";
+return {
+ sameEvent: next.steps.confirm_and_select.events[0] === state.steps.confirm_and_select.events[0],
+ eventName: next.steps.confirm_and_select.events[0].data.detail.candidateName,
+ costItemName: next.steps.confirm_and_select.events[0].data.detail.costItems[0].name
+};
+"""
+ )
+
+ assert output == {
+ "sameEvent": False,
+ "eventName": "旧事件",
+ "costItemName": "ecs",
+ }
+
+
+def test_upsert_candidate_deep_clones_nested_payload() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+const candidate = {
+ name: "方案",
+ candidateIndex: 0,
+ metadata: {source: {tool: "planner"}},
+ costItems: [{name: "ecs", detail: {region: "cn-hangzhou"}}]
+};
+const next = reducers.upsertCandidate(state, candidate);
+candidate.name = "mutated";
+candidate.metadata.source.tool = "mutated";
+candidate.costItems[0].detail.region = "mutated";
+return {
+ name: next.candidates[0].name,
+ tool: next.candidates[0].metadata.source.tool,
+ region: next.candidates[0].costItems[0].detail.region
+};
+"""
+ )
+
+ assert output == {
+ "name": "方案",
+ "tool": "planner",
+ "region": "cn-hangzhou",
+ }
+
+
+def test_reducer_events_only_payload_does_not_duplicate_first_event() -> None:
+ output = reducer_harness(
+ """
+const next = reducers.reducePipelinePayload(reducers.createInitialState({}), {
+ events: [
+ {eventType: "step_completed", sequence: 1, step: {id: "architecture_planning", status: "completed"}},
+ {eventType: "step_completed", sequence: 2, step: {id: "evaluate_candidates", status: "completed"}}
+ ]
+});
+return {
+ architectureEvents: next.steps.architecture_planning.events.length,
+ evaluateEvents: next.steps.evaluate_candidates.events.length,
+ lastSequence: next.lastSequence
+};
+"""
+ )
+
+ assert output == {
+ "architectureEvents": 1,
+ "evaluateEvents": 1,
+ "lastSequence": 2,
+ }
+
+
+def test_create_initial_state_does_not_alias_defaults_object() -> None:
+ output = reducer_harness(
+ """
+const defaults = {serverUrl: "http://server", cwd: "/workspace", nested: {mode: "x"}};
+const state = reducers.createInitialState(defaults);
+defaults.serverUrl = "mutated";
+defaults.nested.mode = "mutated";
+return {
+ serverUrl: state.serverUrl,
+ defaultsServerUrl: state.defaults.serverUrl,
+ defaultsMode: state.defaults.nested.mode
+};
+"""
+ )
+
+ assert output == {
+ "serverUrl": "http://server",
+ "defaultsServerUrl": "http://server",
+ "defaultsMode": "x",
+ }
+
+
+def test_reducer_clones_existing_defaults_when_cloning_state() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({serverUrl: "http://server", cwd: "/workspace", nested: {mode: "x"}});
+const next = reducers.reducePipelinePayload(state, {
+ metadata: {iac_code: {pipeline: {
+ eventType: "step_completed",
+ step: {id: "architecture_planning", status: "completed"}
+ }}}
+});
+state.defaults.nested.mode = "mutated";
+return {
+ sameDefaults: next.defaults === state.defaults,
+ nextMode: next.defaults.nested.mode
+};
+"""
+ )
+
+ assert output == {
+ "sameDefaults": False,
+ "nextMode": "x",
+ }
+
+
+def test_build_stream_payload_uses_active_task_before_handoff() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({serverUrl: "http://server", cwd: "/workspace"});
+state.contextId = "ctx-1";
+state.pipelineTaskId = "pipeline-task";
+state.activeTaskId = "active-task";
+const beforeHandoff = reducers.buildStreamPayload(state, "部署 nginx");
+state.normalHandoffReady = true;
+const afterHandoff = reducers.buildStreamPayload(state, "继续部署");
+return {
+ beforeHandoff,
+ afterHandoff
+};
+"""
+ )
+
+ assert output == {
+ "beforeHandoff": {
+ "serverUrl": "http://server",
+ "cwd": "/workspace",
+ "contextId": "ctx-1",
+ "taskId": "active-task",
+ "prompt": "部署 nginx",
+ },
+ "afterHandoff": {
+ "serverUrl": "http://server",
+ "cwd": "/workspace",
+ "contextId": "ctx-1",
+ "taskId": "",
+ "prompt": "继续部署",
+ },
+ }
+
+
+def test_candidate_selection_prompt_uses_zero_based_index() -> None:
+ output = reducer_harness(
+ """
+const state = reducers.createInitialState({});
+state.candidates = [
+ {name: "ECS 经典网络方案", candidateIndex: 0},
+ {name: "轻量应用服务器一体化方案", candidateIndex: 1}
+];
+const selected = reducers.selectCandidate(state, 1);
+return {
+ sameState: selected === state,
+ selected: state.selectedCandidateIndex,
+ prompt: reducers.promptForSelectedCandidate(state),
+ emptyPrompt: reducers.promptForSelectedCandidate(reducers.createInitialState({}))
+};
+"""
+ )
+
+ assert output == {
+ "sameState": True,
+ "selected": 1,
+ "prompt": "选择方案1",
+ "emptyPrompt": "",
+ }
+
+
+def test_controller_initially_hides_left_steps_and_composer_progress() -> None:
+ output = controller_harness(
+ """
+controller.init();
+const leftSteps = all("[data-step-id]");
+const progressSteps = all("[data-progress-step]");
+return {
+ leftStepCount: leftSteps.length,
+ progressCount: progressSteps.length,
+ progressHidden: elementById("composer-progress").hidden,
+ variant: elementById("composer-progress").getAttribute("data-progress-variant"),
+ progressText: text(elementById("composer-progress"))
+};
+"""
+ )
+
+ assert output == {
+ "leftStepCount": 0,
+ "progressCount": 0,
+ "progressHidden": True,
+ "variant": "b",
+ "progressText": "",
+ }
+
+
+def test_controller_reveals_composer_progress_after_pipeline_started() -> None:
+ output = controller_harness(
+ """
+controller.init();
+const initialHidden = elementById("composer-progress").hidden;
+const next = reducers.reducePipelinePayload(debug.state(), {
+ metadata: {iac_code: {pipeline: {
+ eventType: "pipeline_started",
+ status: "working",
+ taskId: "task-1"
+ }}}
+});
+Object.assign(debug.state(), next);
+debug.render();
+const progressSteps = all("[data-progress-step]");
+return {
+ initialHidden,
+ progressHidden: elementById("composer-progress").hidden,
+ mode: elementById("composer-progress").getAttribute("data-progress-mode"),
+ progressCount: progressSteps.length,
+ progressStatuses: progressSteps.map((step) => step.getAttribute("data-status")),
+ progressText: text(elementById("composer-progress"))
+};
+"""
+ )
+
+ assert output == {
+ "initialHidden": True,
+ "progressHidden": False,
+ "mode": "pipeline",
+ "progressCount": 5,
+ "progressStatuses": ["pending", "pending", "pending", "pending", "pending"],
+ "progressText": "需求理解架构规划方案评估方案选择确认部署",
+ }
+
+
+def test_selling_console_chat_column_is_two_thirds_original_width() -> None:
+ css = STYLES_CSS.read_text(encoding="utf-8")
+
+ assert "grid-template-columns: minmax(280px, 400px) minmax(0, 1fr) 56px;" in css
+ assert "grid-template-columns: minmax(240px, 347px) minmax(0, 1fr);" in css
+
+
+def test_selling_console_removes_left_ai_navigation_rail() -> None:
+ index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8")
+ css = STYLES_CSS.read_text(encoding="utf-8")
+
+ assert 'class="ai-rail"' not in index_html
+ assert "rail-bot" not in index_html
+ assert "rail-button" not in index_html
+ assert ".ai-rail" not in css
+
+
+def test_selling_console_left_chat_scrolls_without_moving_composer() -> None:
+ css = STYLES_CSS.read_text(encoding="utf-8")
+ workflow_rule = css.split(".workflow-panel {", 1)[1].split("}", 1)[0]
+ step_list_rule = css.split(".step-list {", 1)[1].split("}", 1)[0]
+ completed_rule = css.split(".step-card.completed {", 1)[1].split("}", 1)[0]
+ composer_rule = css.split(".composer {", 1)[1].split("}", 1)[0]
+
+ assert "height: calc(100vh - 96px);" in workflow_rule
+ assert "overflow: hidden;" in workflow_rule
+ assert "align-content: start;" in step_list_rule
+ assert "align-items: start;" in step_list_rule
+ assert "overflow-y: auto;" in step_list_rule
+ assert "min-height: 0;" in step_list_rule
+ assert "flex: 1 1 auto;" in step_list_rule
+ assert "gap: 5px;" in step_list_rule
+ assert "padding: 8px 14px;" in step_list_rule
+ assert "grid-template-columns: 24px 1fr;" in completed_rule
+ assert "padding: 6px 8px;" in completed_rule
+ assert "flex: 0 0 auto;" in composer_rule
+ assert "border-top:" not in composer_rule
+
+
+def test_selling_console_chat_messages_have_im_layout_and_avatars() -> None:
+ css = STYLES_CSS.read_text(encoding="utf-8")
+
+ assert ".chat-message.user" in css
+ assert ".chat-message.system" in css
+ assert ".chat-avatar.system" in css
+ assert ".chat-avatar.user" in css
+
+
+def test_selling_console_chat_and_progress_use_compact_spacing() -> None:
+ css = STYLES_CSS.read_text(encoding="utf-8")
+ chat_message_rule = css.split(".chat-message {", 1)[1].split("}", 1)[0]
+ step_title_rule = css.split(".step-card h2 {", 1)[1].split("}", 1)[0]
+ composer_rule = css.split(".composer {", 1)[1].split("}", 1)[0]
+ composer_progress_rule = css.split(".composer-progress:not([hidden]) {", 1)[1].split("}", 1)[0]
+ signal_circuit_rule = css.split(".signal-circuit {", 1)[1].split("}", 1)[0]
+ signal_svg_rule = css.split(".signal-svg {", 1)[1].split("}", 1)[0]
+ signal_labels_rule = css.split(".signal-labels {", 1)[1].split("}", 1)[0]
+
+ assert "gap: 7px;" in chat_message_rule
+ assert "font-size: 13px;" in step_title_rule
+ assert "padding: 6px 14px 10px;" in composer_rule
+ assert "margin-bottom: 8px;" in composer_progress_rule
+ assert "padding-bottom: 8px;" in composer_progress_rule
+ assert "height: 50px;" in signal_circuit_rule
+ assert "height: 36px;" in signal_svg_rule
+ assert "top: 32px;" in signal_labels_rule
+
+
+def test_selling_console_step_rows_hide_sequence_numbers_and_use_compact_marker() -> None:
+ css = STYLES_CSS.read_text(encoding="utf-8")
+ step_index_rule = css.split(".step-index {", 1)[1].split("}", 1)[0]
+
+ assert "step-number" not in APP_JS.read_text(encoding="utf-8")
+ assert "width: 22px;" in step_index_rule
+ assert "height: 22px;" in step_index_rule
+
+
+def test_selling_console_left_intro_and_top_alert_are_visually_hidden() -> None:
+ css = STYLES_CSS.read_text(encoding="utf-8")
+ panel_heading_rule = css.split(".panel-heading {", 1)[1].split("}", 1)[0]
+ status_alert_rule = css.split(".status-alert {", 1)[1].split("}", 1)[0]
+
+ assert "display: none;" in panel_heading_rule
+ assert "display: none;" in status_alert_rule
+
+
+def test_selling_console_composer_uses_compact_input_box() -> None:
+ index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8")
+ css = STYLES_CSS.read_text(encoding="utf-8")
+ composer_rule = css.split(".composer {", 1)[1].split("}", 1)[0]
+ composer_box_rule = css.split(".composer-box {", 1)[1].split("}", 1)[0]
+ input_rule = css.split("#composer-input {", 1)[1].split("}", 1)[0]
+ send_button_rule = css.split(".send-icon-button {", 1)[1].split("}", 1)[0]
+ mobile_compact_rule = css.split("@media (max-width: 560px)", 1)[1].split(".plan-meta", 1)[0]
+
+ assert 'class="composer-box"' in index_html
+ assert 'rows="2"' in index_html
+ assert 'placeholder="继续补充您的需求,比如降低成本、提升可用性或约束地域"' in index_html
+ assert 'aria-label="附件"' in index_html
+ assert 'aria-label="发送"' in index_html
+ assert "padding: 6px 14px 10px;" in composer_rule
+ assert "padding: 10px 10px 9px;" in composer_box_rule
+ assert "min-height: 40px;" in input_rule
+ assert "border: 0;" in input_rule
+ assert "resize: none;" in input_rule
+ assert "width: 36px;" in send_button_rule
+ assert "height: 36px;" in send_button_rule
+ assert ".composer .send-icon-button" in mobile_compact_rule
+ assert "width: 36px;" in mobile_compact_rule
+ assert ".composer .icon-only-button" in mobile_compact_rule
+ assert "width: 32px;" in mobile_compact_rule
+
+
+def test_selling_console_connection_controls_live_in_debug_panel() -> None:
+ index_html = (APP_JS.parent / "index.html").read_text(encoding="utf-8")
+ plan_header = index_html.split('