From a76bb2517e636dfafcae85b405f93be1b6877b71 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 17 Jul 2026 13:38:12 -0700 Subject: [PATCH] feat(jobs): surface text/STRING node outputs in local jobs status as text_outputs (BE-3346) --- comfy_cli/comfy_client.py | 28 +++++++ comfy_cli/command/jobs.py | 20 ++++- .../command/test_transfer_download.py | 48 ++++++++++- tests/comfy_cli/jobs/test_jobs.py | 79 +++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/comfy_cli/comfy_client.py b/comfy_cli/comfy_client.py index 02b8c8eb..326242a9 100644 --- a/comfy_cli/comfy_client.py +++ b/comfy_cli/comfy_client.py @@ -559,6 +559,34 @@ def extract_output_entries(record: dict) -> list[dict]: return results +def extract_text_outputs(record: dict) -> dict[str, list[str]]: + """Group the text/STRING node outputs of a /history record by node id. + + Text-emitting nodes (GeminiNode image descriptions, ShowText, anything + emitting ``ui.text``) surface their payload as a bare-string list under the + ``text`` key of ``outputs[node_id]`` — a shape the media-key flatten in + :func:`extract_output_entries` drops entirely. This is the text counterpart: + for each node whose ``text`` value is a list, keep its ``str`` items and + return ``{node_id: [text, ...]}``. Nodes with no usable text are omitted. + Dict-shape tolerant like the flatten above (a non-dict ``outputs`` or a + non-dict node entry yields ``{}`` / is skipped rather than raising). + """ + results: dict[str, list[str]] = {} + outputs = record.get("outputs") or {} + if not isinstance(outputs, dict): + return results + for node_id, node_output in outputs.items(): + if not isinstance(node_output, dict): + continue + text = node_output.get("text") + if not isinstance(text, list): + continue + strings = [item for item in text if isinstance(item, str)] + if strings: + results[str(node_id)] = strings + return results + + def _group_outputs(outputs: list[dict], item_map: dict | None) -> tuple[dict[str, list[str]], dict[str, list[str]]]: """Group ``Client.extract_outputs`` entries by node and by foreach item. diff --git a/comfy_cli/command/jobs.py b/comfy_cli/command/jobs.py index 9d6db398..2b6464a8 100644 --- a/comfy_cli/command/jobs.py +++ b/comfy_cli/command/jobs.py @@ -635,6 +635,7 @@ def _snapshot(host: str, port: int, prompt_id: str) -> dict | None: "outputs": [], "outputs_by_node": {}, "outputs_by_item": {}, + "text_outputs": {}, "host": host, "port": port, } @@ -663,7 +664,7 @@ def _snapshot(host: str, port: int, prompt_id: str) -> dict | None: # their producing-node association — same flatten the cloud snapshot # uses, so the grouped keys match the cloud envelope shape exactly. from comfy_cli import jobs_state - from comfy_cli.comfy_client import _group_outputs, extract_output_entries + from comfy_cli.comfy_client import _group_outputs, extract_output_entries, extract_text_outputs node_outputs: list[dict] = [] for entry in extract_output_entries(body): @@ -686,6 +687,11 @@ def _snapshot(host: str, port: int, prompt_id: str) -> dict | None: "outputs": output_urls, "outputs_by_node": outputs_by_node, "outputs_by_item": outputs_by_item, + # Text/STRING node outputs (image descriptions, ShowText, …) live under + # outputs[node]["text"] as bare strings, which the URL flatten drops. + # Additive key: full untruncated strings for `--json`; the pretty + # renderer previews them. Empty {} when the run emitted no text. + "text_outputs": extract_text_outputs(body), "error": error_detail, "host": host, "port": port, @@ -715,6 +721,18 @@ def _render_status_pretty(snap: dict, *, host: str, port: int) -> None: tbl.add_row("status", badge) if snap.get("outputs"): tbl.add_row("outputs", "\n".join(snap["outputs"])) + if snap.get("text_outputs"): + # Bounded preview only — first line, ~120 chars per entry. The full + # untruncated text ships on the `--json` path (renderer.emit). + preview_lines = [] + for node_id, texts in snap["text_outputs"].items(): + for text in texts: + first = str(text).splitlines()[0] if str(text).strip() else "" + if len(first) > 120: + first = first[:117] + "…" + preview_lines.append(f"[{node_id}] {first}") + if preview_lines: + tbl.add_row("text", "\n".join(preview_lines)) if snap.get("error"): tbl.add_row("error", str(snap["error"])[:600]) diff --git a/tests/comfy_cli/command/test_transfer_download.py b/tests/comfy_cli/command/test_transfer_download.py index 0a395830..e205a81f 100644 --- a/tests/comfy_cli/command/test_transfer_download.py +++ b/tests/comfy_cli/command/test_transfer_download.py @@ -18,7 +18,7 @@ import pytest from comfy_cli import comfy_client, jobs_state -from comfy_cli.comfy_client import extract_output_entries +from comfy_cli.comfy_client import extract_output_entries, extract_text_outputs from comfy_cli.command import transfer from comfy_cli.output import Renderer, set_renderer from comfy_cli.output.renderer import OutputMode, reset_renderer_for_testing @@ -232,6 +232,52 @@ def test_client_extract_outputs_delegates(self, fake_target): assert outputs[0]["url"] == "https://cloud.example.com/api/view?filename=ComfyUI_a.png&subfolder=&type=output" +class TestExtractTextOutputs: + """Text/STRING node outputs (GeminiNode descriptions, ShowText, …) live as + bare-string lists under ``outputs[node]["text"]`` — a shape the media-key + flatten drops. This pure helper groups them by node id.""" + + def test_groups_text_by_node(self): + record = { + "outputs": { + "7": {"text": ["a cat sitting on a mat", "second string"]}, + "9": {"images": [{"filename": "x.png", "type": "output"}]}, + "11": {"text": ["only line"]}, + } + } + assert extract_text_outputs(record) == { + "7": ["a cat sitting on a mat", "second string"], + "11": ["only line"], + } + + def test_no_text_yields_empty(self): + # A completed run with only media outputs -> {} (never raises). + record = {"outputs": {"9": {"images": [{"filename": "x.png", "type": "output"}]}}} + assert extract_text_outputs(record) == {} + assert extract_text_outputs({}) == {} + assert extract_text_outputs({"outputs": {}}) == {} + + def test_non_list_text_is_skipped(self): + # Some nodes stash a bare string under "text"; only lists are kept. + assert extract_text_outputs({"outputs": {"7": {"text": "not a list"}}}) == {} + assert extract_text_outputs({"outputs": {"7": {"text": None}}}) == {} + + def test_mixed_type_items_keep_only_strings(self): + record = {"outputs": {"7": {"text": ["keep", 42, None, {"nested": 1}, "also keep"]}}} + assert extract_text_outputs(record) == {"7": ["keep", "also keep"]} + # A list with no string items drops the node entirely. + assert extract_text_outputs({"outputs": {"7": {"text": [1, 2, 3]}}}) == {} + + def test_non_dict_outputs_and_nodes_tolerated(self): + assert extract_text_outputs({"outputs": "garbage"}) == {} + assert extract_text_outputs({"outputs": None}) == {} + assert extract_text_outputs({"outputs": {"7": "garbage", "9": {"text": ["ok"]}}}) == {"9": ["ok"]} + + def test_node_ids_coerced_to_str(self): + # /history keys can arrive as ints in some serializations. + assert extract_text_outputs({"outputs": {7: {"text": ["x"]}}}) == {"7": ["x"]} + + class TestCollisionSafeNaming: """A retry fan-out reusing the same item ids re-downloads into the same out-dir; attempt 1 must never be silently clobbered (fennec friction #3, diff --git a/tests/comfy_cli/jobs/test_jobs.py b/tests/comfy_cli/jobs/test_jobs.py index 5ed15898..c1939d8e 100644 --- a/tests/comfy_cli/jobs/test_jobs.py +++ b/tests/comfy_cli/jobs/test_jobs.py @@ -181,6 +181,63 @@ def fake_get(url, timeout=10.0): assert snap["outputs_by_item"] == {} +class TestLocalSnapshotTextOutputs: + """`_snapshot` surfaces text/STRING node outputs under an always-present + additive `text_outputs` key: grouped-by-node full strings on the history + branch, `{}` when there's no text or the job is still queued/running.""" + + def _patch_history(self, monkeypatch, prompt_id, body): + def fake_get(url, timeout=10.0): + if url.endswith("/queue"): + return {"queue_running": [], "queue_pending": []} + if url.endswith(f"/history/{prompt_id}"): + return {prompt_id: body} + raise AssertionError(url) + + monkeypatch.setattr(jobs_mod, "_http_get_json", fake_get) + + def test_history_snapshot_groups_text_by_node(self, monkeypatch): + body = { + "status": {"completed": True, "messages": []}, + "outputs": { + "7": {"text": ["a detailed image description that stays untruncated"]}, + "9": {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]}, + }, + } + self._patch_history(monkeypatch, "txt-1", body) + + snap = jobs_mod._snapshot("h", 8188, "txt-1") + assert snap is not None + assert snap["status"] == "completed" + # Full, untruncated strings grouped by producing node. + assert snap["text_outputs"] == {"7": ["a detailed image description that stays untruncated"]} + # Existing media keys are byte-identical to before — additive only. + assert snap["outputs"] == ["http://h:8188/view?filename=a.png&subfolder=&type=output"] + + def test_history_snapshot_without_text_emits_empty(self, monkeypatch): + body = { + "status": {"completed": True, "messages": []}, + "outputs": {"9": {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]}}, + } + self._patch_history(monkeypatch, "txt-none", body) + + snap = jobs_mod._snapshot("h", 8188, "txt-none") + assert snap is not None + assert snap["text_outputs"] == {} + + def test_queue_snapshot_emits_empty_text_outputs(self, monkeypatch): + def fake_get(url, timeout=10.0): + if url.endswith("/queue"): + return {"queue_running": [[0, "txt-live", {"a": {}}, {}, {}]], "queue_pending": []} + raise AssertionError(url) + + monkeypatch.setattr(jobs_mod, "_http_get_json", fake_get) + snap = jobs_mod._snapshot("h", 8188, "txt-live") + assert snap is not None + assert snap["status"] == "running" + assert snap["text_outputs"] == {} + + def test_safe_queue_entry_handles_short_rows(): assert jobs_mod._safe_queue_entry([0, "id", {"node": {}}]) == ("id", {"node": {}}) assert jobs_mod._safe_queue_entry([])[0] == "?" @@ -1009,6 +1066,28 @@ def fake_time(): assert state.error is None +def test_render_status_pretty_previews_text_truncated(monkeypatch, capsys): + """Pretty render shows a bounded per-entry text preview (first line, ~120 + chars); the full untruncated string is reserved for the `--json` path.""" + from comfy_cli.output import Renderer, set_renderer + from comfy_cli.output.renderer import OutputMode + + set_renderer(Renderer(mode=OutputMode.PRETTY)) + tail = "TAILMARKER" + "X" * 400 + snap = { + "prompt_id": "p", + "status": "completed", + "outputs": [], + # First line is long enough to truncate; a second line must be dropped. + "text_outputs": {"7": [f"HEADMARKER {'Y' * 130}\ndropped second line {tail}"]}, + } + jobs_mod._render_status_pretty(snap, host="h", port=8188) + out = capsys.readouterr().out + assert "HEADMARKER" in out # first line previewed + assert "…" in out # truncated past ~120 chars + assert tail not in out # second line never rendered + + def test_emit_terminal_verdicts(): import typer