From 3643b25ca8eba671d887ca343845fa6178ea8ca1 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Thu, 24 Sep 2026 23:12:09 +0200 Subject: [PATCH] Qualify the demos' pipe_code as domain.pipe_code Every demo command, in all three modes, now sends the pipe as the bundle's domain plus its main pipe (extract_entities.extract_entities) instead of the bare code. The runtime resolves the qualified form as an exact key, while a bare code is searched across every domain of the bundle and fails as ambiguous once two domains declare it, and the demos are the code users copy. The qualified form couples each call site to its bundle's domain, so test_mode_symmetry.py now reads the reference from each bundle and checks every demo in every mode against it. The e2e tests, the status fixture (the platform records the pipe_code a run was started with), the docs and the changelog follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N7PSUoKkQLc7VcS7AkFJkM --- CHANGELOG.md | 4 +++ README.md | 2 +- docs/add-method.md | 2 +- docs/cli-architecture.md | 4 ++- docs/codegen.md | 2 +- tests/e2e/test_extract_entities.py | 2 +- tests/e2e/test_generate_image.py | 2 +- tests/e2e/test_summarize_pdf.py | 2 +- tests/unit/test_attended_cli.py | 2 +- tests/unit/test_blocking_cli.py | 2 +- tests/unit/test_detached_cli.py | 9 ++++-- tests/unit/test_mode_symmetry.py | 48 +++++++++++++++++++++++++++++- widget/attended/cli.py | 6 ++-- widget/blocking/cli.py | 6 ++-- widget/detached/cli.py | 6 ++-- 15 files changed, 77 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8573523..c5b9884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **The demos name their pipe by its qualified reference**: every demo command, in every mode, sends `pipe_code` as `domain.pipe_code` (`extract_entities.extract_entities`) rather than the bare code, the exact key the runtime resolves. A bare code is searched for across every domain of the bundle and fails as ambiguous once two domains declare it, so code copied from a demo keeps working as its bundle grows; rename a bundle's `domain` and its call sites together. + ## [v0.1.0] - 2026-09-22 ### Highlights diff --git a/README.md b/README.md index a42312b..dc1e989 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ You get the extracted entities as JSON: ## Try the demos -Each demo is one self-contained `widget` command — a bundle path, a pipe code, and a typed narrowing of the result into its *generated* model. Every demo exists in every execution mode; the commands below use `blocking`, the simplest one. +Each demo is one self-contained `widget` command — a bundle path, the pipe's qualified reference (`domain.pipe_code`), and a typed narrowing of the result into its *generated* model. Every demo exists in every execution mode; the commands below use `blocking`, the simplest one. **Every demo runs with no arguments.** Give it nothing and it uses a bundled sample (and tells you so on stderr), so you can see a working result before you have any input of your own — then pass your own text, prompt, or file to replace it: diff --git a/docs/add-method.md b/docs/add-method.md index 410e114..d5053a9 100644 --- a/docs/add-method.md +++ b/docs/add-method.md @@ -91,7 +91,7 @@ A published package can carry several pipes, so the pipe is chosen by a rule tha 3. The only pipe, when the method declares exactly one. 4. Otherwise a refusal listing the pipes and asking for `PIPE`. -The command sends the chosen ref **qualified** (`pipe_code="stats.analyze_text"`) beside the selector. A bare code would be ambiguous in exactly the case step 1 refuses to guess at — a code two domains of the method both declare — and the runtime resolves a `domain.pipe_code` directly, so the qualified ref is right on every path. The demo commands send bare codes because their bundles have one domain each. +The command sends the chosen ref **qualified** (`pipe_code="stats.analyze_text"`) beside the selector. A bare code would be ambiguous in exactly the case step 1 refuses to guess at — a code two domains of the method both declare — and the runtime resolves a `domain.pipe_code` directly, so the qualified ref is right on every path. The demo commands send qualified refs too, for the same reason. ## The command's parameters are the method's inputs diff --git a/docs/cli-architecture.md b/docs/cli-architecture.md index 3224784..e231133 100644 --- a/docs/cli-architecture.md +++ b/docs/cli-architecture.md @@ -65,7 +65,7 @@ Three SDK capabilities show up in every result-producing path: - **Produced files.** A run that generates an image or a document does not return the bytes: the output carries a durable `pipelex-storage://` reference beside a signed `public_url` that expires, so the link must not be stored. `widget/artifacts.py` brings the files down through the SDK's artifact stack — `collect_artifacts` answers offline whether the output references any file at all, so a text result costs nothing, and `download_artifacts` mints a fresh link for each and saves it under `DEFAULT_DOWNLOAD_DIR`, never reading the embedded `public_url` and never overwriting a file. It answers a verdict with errors as values, so a file that did not come down is reported rather than raised. See `docs/artifact-download.md` in `pipelex-sdk`. - **File upload.** `summarize-pdf` feeds a *file* to a pipe. A hosted run cannot see your filesystem, so `inputs.py`'s `upload_document_input` uploads the file first (`client.upload_file`) and the run request carries only the returned `pipelex-storage://` URI — never the bytes. Preparation is a step of its own, before the run: an unreadable file or an upload-incapable deployment fails before any run is created, presented through the same `widget/errors.py` path as every other SDK error. -## Two conventions worth copying +## Conventions worth copying **stdout is the result; stderr is everything else.** Progress spinners, run ids in attended mode, error messages, and hints all go to stderr, so stdout stays pipeable. In detached mode the run id *is* the result, so it goes to stdout bare (`print`, not Rich) — `RUN_ID=$(widget detached generate-image "…")` just works. @@ -77,6 +77,8 @@ The hints name the mode *groups*, because the fix for a failed run is usually an **Every demo runs with zero arguments.** When you give neither an argument nor `--file`, the input helper returns a bundled sample (`widget/inputs.py`'s `SAMPLE_*` constants), and the command prints a one-line notice on stderr saying so. A fresh clone shows a working result on its very first command once your API key is set; stdout stays the clean, pipeable result because the notice is on stderr. Sample data is orthogonal to execution, so like input encoding it is shared, not duplicated per mode. +**A demo names its pipe by its qualified reference.** Each call sends `pipe_code="."` — `extract_entities.extract_entities`, the bundle's own `domain` and its `main_pipe` — never the bare code. The runtime keys a pipe by exactly that reference, while a bare code is searched for across every domain of the bundle and fails as ambiguous once two domains declare the same one, which a bundle you grow from a demo can easily come to do. The qualified form ties the call site to the bundle's `domain`, so rename the two together; `tests/unit/test_mode_symmetry.py` reads the reference from each bundle and fails when a call site no longer matches it. + ## Why `attended` and `detached`, not `durable` Both start the *same* durable run — one that lives server-side behind an id and outlives your terminal. The only difference is who waits: `attended` polls from your terminal, `detached` exits and lets you collect the result later. Naming the middle one "durable" would suggest detached is not, which is exactly backwards. The mode names the axis that actually differs. diff --git a/docs/codegen.md b/docs/codegen.md index 7825503..65b15fb 100644 --- a/docs/codegen.md +++ b/docs/codegen.md @@ -11,7 +11,7 @@ The typed models this starter parses run results into are **generated from the ` Each `models.py` starts with a `pipelex-codegen-stamp` header recording the source crate fingerprint, engine version, projection, and a content hash. The sibling `codegen.lock` records the generated artifact set. Together they make drift detectable offline. -The demo commands in each mode CLI (`widget/blocking/cli.py`, `widget/attended/cli.py`) import the generated models and only add the bundle path, the pipe code, and the narrowing line (`Model.model_validate(main_stuff)`) — nothing method-shaped is hand-written. +The demo commands in each mode CLI (`widget/blocking/cli.py`, `widget/attended/cli.py`) import the generated models and only add the bundle path, the pipe's qualified reference, and the narrowing line (`Model.model_validate(main_stuff)`) — nothing method-shaped is hand-written. ## Workflow diff --git a/tests/e2e/test_extract_entities.py b/tests/e2e/test_extract_entities.py index 45b745b..bc53765 100644 --- a/tests/e2e/test_extract_entities.py +++ b/tests/e2e/test_extract_entities.py @@ -18,6 +18,6 @@ async def test_blocking(self): # The blocking lifecycle end to end: one `execute` call, then narrow. # Extraction finishes well under the hosted ~30s cap, so the blocking mode owns this demo. bundle = BUNDLE_PATH.read_text() - results = await execute_pipe(pipe_code="extract_entities", mthds_contents=[bundle], inputs={"text": SAMPLE_TEXT}) + results = await execute_pipe(pipe_code="extract_entities.extract_entities", mthds_contents=[bundle], inputs={"text": SAMPLE_TEXT}) entities = ExtractedEntities.model_validate(results.main_stuff) assert any("Curie" in person for person in entities.people) diff --git a/tests/e2e/test_generate_image.py b/tests/e2e/test_generate_image.py index cee24ee..9c158dc 100644 --- a/tests/e2e/test_generate_image.py +++ b/tests/e2e/test_generate_image.py @@ -17,7 +17,7 @@ async def test_detached(self): # start, get an id back, then pick the run up again through that id alone. # Image generation outlives the ~30s blocking cap, so it is the demo detached mode owns. bundle = BUNDLE_PATH.read_text() - run_id = await start_pipe(pipe_code="generate_image", mthds_contents=[bundle], inputs={"image_prompt": SAMPLE_PROMPT}) + run_id = await start_pipe(pipe_code="generate_image.generate_image", mthds_contents=[bundle], inputs={"image_prompt": SAMPLE_PROMPT}) assert run_id results = await attend_run(run_id) image = Image.model_validate(results.main_stuff) diff --git a/tests/e2e/test_summarize_pdf.py b/tests/e2e/test_summarize_pdf.py index fc72a5f..5439eb2 100644 --- a/tests/e2e/test_summarize_pdf.py +++ b/tests/e2e/test_summarize_pdf.py @@ -19,7 +19,7 @@ async def test_attended(self): # The attended lifecycle end to end: upload the PDF, start a durable run, poll it, narrow. bundle = BUNDLE_PATH.read_text() inputs = {"document": await upload_document_input(SAMPLE_PDF)} - results = await start_and_wait(pipe_code="summarize_pdf", mthds_contents=[bundle], inputs=inputs) + results = await start_and_wait(pipe_code="summarize_pdf.summarize_pdf", mthds_contents=[bundle], inputs=inputs) summary = DocumentSummary.model_validate(results.main_stuff) assert summary.title assert summary.doc_type diff --git a/tests/unit/test_attended_cli.py b/tests/unit/test_attended_cli.py index 259199e..c9aa3a3 100644 --- a/tests/unit/test_attended_cli.py +++ b/tests/unit/test_attended_cli.py @@ -41,7 +41,7 @@ def test_extract_entities_starts_waits_and_prints_result(self, mocker: MockerFix assert result.exit_code == 0 attended_mock.assert_awaited_once() assert attended_mock.await_args is not None - assert attended_mock.await_args.kwargs["pipe_code"] == "extract_entities" + assert attended_mock.await_args.kwargs["pipe_code"] == "extract_entities.extract_entities" assert attended_mock.await_args.kwargs["inputs"] == {"text": "some text"} assert "Marie Curie" in result.output diff --git a/tests/unit/test_blocking_cli.py b/tests/unit/test_blocking_cli.py index 95053e2..8241f6f 100644 --- a/tests/unit/test_blocking_cli.py +++ b/tests/unit/test_blocking_cli.py @@ -41,7 +41,7 @@ def test_extract_entities_executes_and_prints_result(self, mocker: MockerFixture assert result.exit_code == 0 execute_mock.assert_awaited_once() assert execute_mock.await_args is not None - assert execute_mock.await_args.kwargs["pipe_code"] == "extract_entities" + assert execute_mock.await_args.kwargs["pipe_code"] == "extract_entities.extract_entities" assert execute_mock.await_args.kwargs["inputs"] == {"text": "some text"} assert "Marie Curie" in result.output diff --git a/tests/unit/test_detached_cli.py b/tests/unit/test_detached_cli.py index ac9542d..8ee2c9c 100644 --- a/tests/unit/test_detached_cli.py +++ b/tests/unit/test_detached_cli.py @@ -41,7 +41,7 @@ def test_extract_entities_starts_the_run_and_prints_its_id(self, mocker: MockerF assert result.exit_code == 0 start_mock.assert_awaited_once() assert start_mock.await_args is not None - assert start_mock.await_args.kwargs["pipe_code"] == "extract_entities" + assert start_mock.await_args.kwargs["pipe_code"] == "extract_entities.extract_entities" assert start_mock.await_args.kwargs["inputs"] == {"text": "some text"} # The bare id on stdout is the contract: RUN_ID=$(widget detached extract-entities "…") assert result.stdout.strip() == RUN_ID @@ -128,13 +128,16 @@ def test_wait_brings_down_the_files_the_run_produced(self, mocker: MockerFixture assert stub_download in result.output def test_status_reports_the_run_status(self, mocker: MockerFixture): - run = RunRead(pipeline_run_id=RUN_ID, pipe_code="extract_entities", status=RunStatus.RUNNING, created_at="2026-07-13T10:00:00Z") + # The platform records the pipe_code the run was started with, which the demos send qualified. + run = RunRead( + pipeline_run_id=RUN_ID, pipe_code="extract_entities.extract_entities", status=RunStatus.RUNNING, created_at="2026-07-13T10:00:00Z" + ) mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) result = runner.invoke(app, ["detached", "status", RUN_ID]) assert result.exit_code == 0 assert RUN_ID in result.output assert "RUNNING" in result.output - assert "extract_entities" in result.output + assert "extract_entities.extract_entities" in result.output def test_status_flags_a_degraded_reading(self, mocker: MockerFixture): run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.RUNNING, created_at="2026-07-13T10:00:00Z", degraded=True) diff --git a/tests/unit/test_mode_symmetry.py b/tests/unit/test_mode_symmetry.py index c5f8f1b..2c75097 100644 --- a/tests/unit/test_mode_symmetry.py +++ b/tests/unit/test_mode_symmetry.py @@ -3,13 +3,21 @@ Every demo exists in every mode group, with the same arguments — that symmetry is the pedagogy (diff two mode files and only the lifecycle helper differs), and the duplication it implies is exactly what drifts. A demo added to one mode and -forgotten in another fails here. +forgotten in another fails here, and so does a demo whose call site no longer +names its own bundle's pipe. """ import inspect +import tomllib +from pathlib import Path +import pytest import typer +from pipelex_sdk.runs import RunResults +from pytest_mock import MockerFixture +from typer.testing import CliRunner +import widget from widget.attended.cli import app as attended_app from widget.blocking.cli import app as blocking_app from widget.cli import app as root_app @@ -18,12 +26,30 @@ MODE_APPS = {"blocking": blocking_app, "attended": attended_app, "detached": detached_app} DEMO_COMMANDS = {"extract-entities", "summarize-pdf", "generate-image"} LIFECYCLE_COMMANDS = {"wait", "status", "result"} +# Each demo command runs the bundle in the method directory of the same name. +METHODS_DIR = Path(widget.__file__).parent / "methods" +# The one public lifecycle helper each mode's demos call, which is what gets patched below. +LIFECYCLE_HELPERS = {"blocking": "execute_pipe", "attended": "start_and_wait", "detached": "start_pipe"} +# Outputs each demo's generated model accepts, so blocking and attended get past their narrowing. +DEMO_OUTPUTS: dict[str, dict[str, object]] = { + "extract-entities": {"people": [], "orgs": [], "dates": []}, + "summarize-pdf": {"title": "Invoice", "doc_type": "invoice", "key_points": []}, + "generate-image": {"url": "pipelex-storage://run-1/cat.png", "public_url": "https://cdn.example.com/signed/cat.png"}, +} + +runner = CliRunner() def _command_names(mode_app: typer.Typer) -> set[str]: return {command.name for command in mode_app.registered_commands if command.name is not None} +def _qualified_main_pipe(demo: str) -> str: + """The demo bundle's main pipe as the runtime keys it: the bundle's own domain, a dot, the pipe's code.""" + bundle = tomllib.loads((METHODS_DIR / demo / "main.mthds").read_text()) + return f"{bundle['domain']}.{bundle['main_pipe']}" + + def _demo_signatures(mode_app: typer.Typer) -> dict[str, list[str]]: signatures: dict[str, list[str]] = {} for command in mode_app.registered_commands: @@ -58,3 +84,23 @@ def test_the_demos_take_the_same_arguments_in_every_mode(self): def test_the_root_app_mounts_the_modes_in_reading_order(self): mounted = [group.name for group in root_app.registered_groups if group.name in MODE_APPS] assert mounted == ["blocking", "attended", "detached"] + + @pytest.mark.usefixtures("stub_upload", "stub_download") + @pytest.mark.parametrize("demo", sorted(DEMO_COMMANDS)) + @pytest.mark.parametrize("mode", MODE_APPS) + def test_every_demo_names_its_pipe_by_its_bundles_qualified_ref(self, mocker: MockerFixture, mode: str, demo: str): + """The runtime keys a pipe by `domain.pipe_code`; a bare code is searched across every domain of the bundle. + + The search fails as ambiguous once two domains declare the same code, so the demos send the exact key. + That key couples the call site to the bundle's `domain`, which a bare code never did: renaming the + domain without the call sites would pass every offline test and fail every live run, unless this + test reads the name from the bundle itself. + """ + returned: RunResults | str = "run-abc123" + if mode != "detached": + returned = RunResults(pipeline_run_id="run-1", main_stuff=DEMO_OUTPUTS[demo], tokens_usages=None, usage_assembly_error=None) + helper = mocker.patch(f"widget.{mode}.cli.{LIFECYCLE_HELPERS[mode]}", return_value=returned) + result = runner.invoke(root_app, [mode, demo]) + assert result.exit_code == 0, result.output + assert helper.await_args is not None + assert helper.await_args.kwargs["pipe_code"] == _qualified_main_pipe(demo) diff --git a/widget/attended/cli.py b/widget/attended/cli.py index fbd5054..8b65648 100644 --- a/widget/attended/cli.py +++ b/widget/attended/cli.py @@ -107,7 +107,7 @@ def extract_entities( if resolved.is_sample: progress_console.print(f"[dim]No text given — using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "extract-entities" / "main.mthds").read_text() - results = _run(start_and_wait(pipe_code="extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) + results = _run(start_and_wait(pipe_code="extract_entities.extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) # Narrow into the generated typed model (validates the concept's shape), then print it as JSON. entities = ExtractedEntities.model_validate(results.main_stuff) output_console.print_json(data=entities.model_dump()) @@ -128,7 +128,7 @@ def summarize_pdf( bundle = (METHODS_DIR / "summarize-pdf" / "main.mthds").read_text() # Upload the file first (a separate step from the run) — the run request carries only its URI. inputs = {"document": _run(upload_document_input(document))} - results = _run(start_and_wait(pipe_code="summarize_pdf", mthds_contents=[bundle], inputs=inputs)) + results = _run(start_and_wait(pipe_code="summarize_pdf.summarize_pdf", mthds_contents=[bundle], inputs=inputs)) summary = DocumentSummary.model_validate(results.main_stuff) output_console.print_json(data=summary.model_dump()) print_cost_report(progress_console, results) @@ -149,7 +149,7 @@ def generate_image( if resolved.is_sample: progress_console.print(f"[dim]No prompt given — using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "generate-image" / "main.mthds").read_text() - results = _run(start_and_wait(pipe_code="generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) + results = _run(start_and_wait(pipe_code="generate_image.generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) # On the hosted path the runtime returns a storage `url` (`pipelex-storage://…`) # *and* a web-renderable `public_url` (a signed URL); the model keeps both. image = Image.model_validate(results.main_stuff) diff --git a/widget/blocking/cli.py b/widget/blocking/cli.py index c389529..8a6cec5 100644 --- a/widget/blocking/cli.py +++ b/widget/blocking/cli.py @@ -87,7 +87,7 @@ def extract_entities( if resolved.is_sample: progress_console.print(f"[dim]No text given — using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "extract-entities" / "main.mthds").read_text() - results = _run(execute_pipe(pipe_code="extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) + results = _run(execute_pipe(pipe_code="extract_entities.extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) # Narrow into the generated typed model (validates the concept's shape), then print it as JSON. entities = ExtractedEntities.model_validate(results.main_stuff) output_console.print_json(data=entities.model_dump()) @@ -108,7 +108,7 @@ def summarize_pdf( bundle = (METHODS_DIR / "summarize-pdf" / "main.mthds").read_text() # Upload the file first (a separate step from the run) — the run request carries only its URI. inputs = {"document": _run(upload_document_input(document))} - results = _run(execute_pipe(pipe_code="summarize_pdf", mthds_contents=[bundle], inputs=inputs)) + results = _run(execute_pipe(pipe_code="summarize_pdf.summarize_pdf", mthds_contents=[bundle], inputs=inputs)) summary = DocumentSummary.model_validate(results.main_stuff) output_console.print_json(data=summary.model_dump()) print_cost_report(progress_console, results) @@ -129,7 +129,7 @@ def generate_image( if resolved.is_sample: progress_console.print(f"[dim]No prompt given — using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "generate-image" / "main.mthds").read_text() - results = _run(execute_pipe(pipe_code="generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) + results = _run(execute_pipe(pipe_code="generate_image.generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) # On the hosted path the runtime returns a storage `url` (`pipelex-storage://…`) # *and* a web-renderable `public_url` (a signed URL); the model keeps both. image = Image.model_validate(results.main_stuff) diff --git a/widget/detached/cli.py b/widget/detached/cli.py index 314b7ce..a3a96db 100644 --- a/widget/detached/cli.py +++ b/widget/detached/cli.py @@ -126,7 +126,7 @@ def extract_entities( if resolved.is_sample: progress_console.print(f"[dim]No text given — using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "extract-entities" / "main.mthds").read_text() - run_id = _run(start_pipe(pipe_code="extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) + run_id = _run(start_pipe(pipe_code="extract_entities.extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) _print_run_id(run_id) @@ -144,7 +144,7 @@ def summarize_pdf( bundle = (METHODS_DIR / "summarize-pdf" / "main.mthds").read_text() # Upload the file first (a separate step from the run) — the run request carries only its URI. inputs = {"document": _run(upload_document_input(document))} - run_id = _run(start_pipe(pipe_code="summarize_pdf", mthds_contents=[bundle], inputs=inputs)) + run_id = _run(start_pipe(pipe_code="summarize_pdf.summarize_pdf", mthds_contents=[bundle], inputs=inputs)) _print_run_id(run_id) @@ -162,7 +162,7 @@ def generate_image( if resolved.is_sample: progress_console.print(f"[dim]No prompt given — using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "generate-image" / "main.mthds").read_text() - run_id = _run(start_pipe(pipe_code="generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) + run_id = _run(start_pipe(pipe_code="generate_image.generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) _print_run_id(run_id)