From f9e3e9ddd3cab8fb9476729bd5a46c9ce007c796 Mon Sep 17 00:00:00 2001 From: Rashmi Date: Tue, 15 Sep 2026 17:52:03 +0530 Subject: [PATCH 1/3] Prompt for human feedback in LangGraph Functional API HITL sample --- .../human_in_the_loop/README.md | 8 +- .../human_in_the_loop/run_workflow.py | 7 +- .../human_in_the_loop/workflow.py | 25 ++-- .../functional_human_in_the_loop_test.py | 117 ++++++++++++++---- 4 files changed, 118 insertions(+), 39 deletions(-) diff --git a/langgraph_plugin/functional_api/human_in_the_loop/README.md b/langgraph_plugin/functional_api/human_in_the_loop/README.md index 9df7ba652..e6db94320 100644 --- a/langgraph_plugin/functional_api/human_in_the_loop/README.md +++ b/langgraph_plugin/functional_api/human_in_the_loop/README.md @@ -22,17 +22,19 @@ Demonstrates pausing an entrypoint with LangGraph's `interrupt()` and waiting in Prerequisites: `uv sync --group langgraph` and a running Temporal dev server (`temporal server start-dev`). ```bash -# Terminal 1 +# Terminal 1: start the worker uv run langgraph_plugin/functional_api/human_in_the_loop/run_worker.py -# Terminal 2 +# Terminal 2: start the workflow (polls for the draft, then prompts you for feedback) uv run langgraph_plugin/functional_api/human_in_the_loop/run_workflow.py ``` +When the draft is ready, you'll be prompted at the terminal. Type `approve` to accept it as-is, or type revision feedback and the draft will be regenerated by an LLM incorporating your notes. + ## Files | File | Description | |------|-------------| | `workflow.py` | `@task` functions, `@entrypoint`, and `ChatbotFunctionalWorkflow` | | `run_worker.py` | Registers tasks and entrypoint with `LangGraphPlugin`, starts worker | -| `run_workflow.py` | Starts workflow, polls draft via query, sends approval via signal | +| `run_workflow.py` | Starts workflow, polls draft via query, prompts for human feedback, sends it via signal | diff --git a/langgraph_plugin/functional_api/human_in_the_loop/run_workflow.py b/langgraph_plugin/functional_api/human_in_the_loop/run_workflow.py index 5074b9370..a9fa5c58e 100644 --- a/langgraph_plugin/functional_api/human_in_the_loop/run_workflow.py +++ b/langgraph_plugin/functional_api/human_in_the_loop/run_workflow.py @@ -28,8 +28,11 @@ async def main() -> None: print(f"Draft for review: {draft}") - # Send approval via signal - await handle.signal(ChatbotFunctionalWorkflow.provide_feedback, "approve") + # Prompt for human feedback instead of auto-approving. + feedback = await asyncio.to_thread( + input, "Enter 'approve' to accept, or type revision feedback: " + ) + await handle.signal(ChatbotFunctionalWorkflow.provide_feedback, feedback) result = await handle.result() print(f"Final response: {result}") diff --git a/langgraph_plugin/functional_api/human_in_the_loop/workflow.py b/langgraph_plugin/functional_api/human_in_the_loop/workflow.py index 857ef37f3..7067a656b 100644 --- a/langgraph_plugin/functional_api/human_in_the_loop/workflow.py +++ b/langgraph_plugin/functional_api/human_in_the_loop/workflow.py @@ -8,6 +8,7 @@ from datetime import timedelta from typing import Any +from langchain.chat_models import init_chat_model from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import InMemorySaver from langgraph.func import entrypoint, task @@ -17,25 +18,31 @@ @task -def generate_draft(message: str) -> str: - """Generate a draft response. Replace with an LLM call in production.""" - return ( - f"Here's my response to '{message}': " - "The answer is 42. Let me know if this helps!" +async def generate_draft(message: str) -> str: + """Generate a draft response with an LLM.""" + response = await init_chat_model("claude-sonnet-4-6").ainvoke( + f"Please respond concisely to: {message}" ) + return str(response.content) @task -def request_human_review(draft: str) -> str: - """Pause execution to request human review of the draft.""" +async def request_human_review(draft: str) -> str: + """Present draft to human for review via interrupt; revise with LLM on feedback.""" feedback = interrupt(draft) if feedback == "approve": return draft - return f"[Revised] {draft} (incorporating feedback: {feedback})" + response = await init_chat_model("claude-sonnet-4-6").ainvoke( + "Revise the following draft according to the reviewer's feedback. " + "Output only the revised draft, with no preamble.\n\n" + f"Draft:\n{draft}\n\n" + f"Feedback:\n{feedback}" + ) + return str(response.content) @entrypoint() -async def chatbot_entrypoint(user_message: str) -> dict: +async def chatbot_entrypoint(user_message: str) -> dict[str, Any]: """Chatbot entrypoint: generate a draft, get human review, return result.""" draft = await generate_draft(user_message) final_response = await request_human_review(draft) diff --git a/tests/langgraph_plugin/functional_human_in_the_loop_test.py b/tests/langgraph_plugin/functional_human_in_the_loop_test.py index 746647f88..2e1d0dfdd 100644 --- a/tests/langgraph_plugin/functional_human_in_the_loop_test.py +++ b/tests/langgraph_plugin/functional_human_in_the_loop_test.py @@ -1,6 +1,7 @@ import asyncio import sys import uuid +from unittest.mock import patch import pytest from temporalio.client import Client @@ -20,6 +21,28 @@ ) +class _FakeMessage: + def __init__(self, content: str) -> None: + self.content = content + + +class _EchoModel: + """Stand-in for a chat model that echoes the prompt back as its response.""" + + async def ainvoke(self, prompt: str) -> _FakeMessage: + return _FakeMessage(prompt) + + +def _fake_init_chat_model(*args: object, **kwargs: object) -> _EchoModel: + return _EchoModel() + + +_patch_llm = lambda: patch( + "langgraph_plugin.functional_api.human_in_the_loop.workflow.init_chat_model", + _fake_init_chat_model, +) + + async def test_functional_human_in_the_loop_approve(client: Client) -> None: task_queue = f"functional-hitl-test-{uuid.uuid4()}" plugin = LangGraphPlugin( @@ -28,31 +51,75 @@ async def test_functional_human_in_the_loop_approve(client: Client) -> None: activity_options=activity_options, ) - async with Worker( - client, - task_queue=task_queue, - workflows=[ChatbotFunctionalWorkflow], - plugins=[plugin], - ): - handle = await client.start_workflow( - ChatbotFunctionalWorkflow.run, - "test message", - id=f"functional-hitl-{uuid.uuid4()}", + with _patch_llm(): + async with Worker( + client, task_queue=task_queue, - ) - - # Poll for draft to be ready - draft = None - for _ in range(40): - await asyncio.sleep(0.25) - draft = await handle.query(ChatbotFunctionalWorkflow.get_draft) - if draft is not None: - break - assert draft is not None - assert "test message" in draft - - # Approve - await handle.signal(ChatbotFunctionalWorkflow.provide_feedback, "approve") - result = await handle.result() + workflows=[ChatbotFunctionalWorkflow], + plugins=[plugin], + ): + handle = await client.start_workflow( + ChatbotFunctionalWorkflow.run, + "test message", + id=f"functional-hitl-{uuid.uuid4()}", + task_queue=task_queue, + ) + + # Poll for draft to be ready + draft = None + for _ in range(40): + await asyncio.sleep(0.25) + draft = await handle.query(ChatbotFunctionalWorkflow.get_draft) + if draft is not None: + break + assert draft is not None + assert "test message" in draft + + # Approve + await handle.signal(ChatbotFunctionalWorkflow.provide_feedback, "approve") + result = await handle.result() assert result["response"] == draft + + +async def test_functional_human_in_the_loop_revise(client: Client) -> None: + task_queue = f"functional-hitl-revise-test-{uuid.uuid4()}" + plugin = LangGraphPlugin( + entrypoints={"chatbot": chatbot_entrypoint}, + tasks=all_tasks, + activity_options=activity_options, + ) + + with _patch_llm(): + async with Worker( + client, + task_queue=task_queue, + workflows=[ChatbotFunctionalWorkflow], + plugins=[plugin], + ): + handle = await client.start_workflow( + ChatbotFunctionalWorkflow.run, + "test message", + id=f"functional-hitl-revise-{uuid.uuid4()}", + task_queue=task_queue, + ) + + # Poll for draft + draft = None + for _ in range(40): + await asyncio.sleep(0.25) + draft = await handle.query(ChatbotFunctionalWorkflow.get_draft) + if draft is not None: + break + assert draft is not None + + # Send revision feedback + await handle.signal( + ChatbotFunctionalWorkflow.provide_feedback, "please be more concise" + ) + result = await handle.result() + + # The revision task feeds the draft and feedback into the LLM; the echo + # stand-in returns the revision prompt, which contains both. + assert "please be more concise" in result["response"] + assert "test message" in result["response"] From e17556044ad902380472633f1be18af7ec2787d2 Mon Sep 17 00:00:00 2001 From: Rashmi Date: Tue, 15 Sep 2026 23:00:04 +0530 Subject: [PATCH 2/3] Make LangGraph HITL samples model-free Rewrite both graph_api and functional_api human-in-the-loop samples to be deliberately model-free without requiring an Anthropic API key. Draft generation and revision use deterministic templates while preserving the interactive terminal review workflow. Update unit tests and documentation accordingly. --- .../human_in_the_loop/README.md | 2 +- .../human_in_the_loop/workflow.py | 19 +-- .../graph_api/human_in_the_loop/README.md | 2 +- .../graph_api/human_in_the_loop/workflow.py | 23 ++- .../functional_human_in_the_loop_test.py | 139 ++++++++---------- .../human_in_the_loop_test.py | 131 +++++++---------- 6 files changed, 129 insertions(+), 187 deletions(-) diff --git a/langgraph_plugin/functional_api/human_in_the_loop/README.md b/langgraph_plugin/functional_api/human_in_the_loop/README.md index e6db94320..c0278b985 100644 --- a/langgraph_plugin/functional_api/human_in_the_loop/README.md +++ b/langgraph_plugin/functional_api/human_in_the_loop/README.md @@ -29,7 +29,7 @@ uv run langgraph_plugin/functional_api/human_in_the_loop/run_worker.py uv run langgraph_plugin/functional_api/human_in_the_loop/run_workflow.py ``` -When the draft is ready, you'll be prompted at the terminal. Type `approve` to accept it as-is, or type revision feedback and the draft will be regenerated by an LLM incorporating your notes. +When the draft is ready, you'll be prompted at the terminal. Type `approve` to accept it as-is, or type revision feedback and the draft will be revised incorporating your notes. ## Files diff --git a/langgraph_plugin/functional_api/human_in_the_loop/workflow.py b/langgraph_plugin/functional_api/human_in_the_loop/workflow.py index 7067a656b..45f91931d 100644 --- a/langgraph_plugin/functional_api/human_in_the_loop/workflow.py +++ b/langgraph_plugin/functional_api/human_in_the_loop/workflow.py @@ -8,7 +8,6 @@ from datetime import timedelta from typing import Any -from langchain.chat_models import init_chat_model from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import InMemorySaver from langgraph.func import entrypoint, task @@ -19,26 +18,20 @@ @task async def generate_draft(message: str) -> str: - """Generate a draft response with an LLM.""" - response = await init_chat_model("claude-sonnet-4-6").ainvoke( - f"Please respond concisely to: {message}" + """Generate a draft response. Replace with an LLM call in production.""" + return ( + f"Here's my response to '{message}': " + "The answer is 42. Let me know if this helps!" ) - return str(response.content) @task async def request_human_review(draft: str) -> str: - """Present draft to human for review via interrupt; revise with LLM on feedback.""" + """Present draft to human for review via interrupt; revise on feedback.""" feedback = interrupt(draft) if feedback == "approve": return draft - response = await init_chat_model("claude-sonnet-4-6").ainvoke( - "Revise the following draft according to the reviewer's feedback. " - "Output only the revised draft, with no preamble.\n\n" - f"Draft:\n{draft}\n\n" - f"Feedback:\n{feedback}" - ) - return str(response.content) + return f"[Revised] {draft} (incorporating feedback: {feedback})" @entrypoint() diff --git a/langgraph_plugin/graph_api/human_in_the_loop/README.md b/langgraph_plugin/graph_api/human_in_the_loop/README.md index d8ff0b48c..85eccf623 100644 --- a/langgraph_plugin/graph_api/human_in_the_loop/README.md +++ b/langgraph_plugin/graph_api/human_in_the_loop/README.md @@ -29,7 +29,7 @@ uv run langgraph_plugin/graph_api/human_in_the_loop/run_worker.py uv run langgraph_plugin/graph_api/human_in_the_loop/run_workflow.py ``` -When the draft is ready, you'll be prompted at the terminal. Type `approve` to accept it as-is, or type revision feedback and the draft will be regenerated by an LLM incorporating your notes. +When the draft is ready, you'll be prompted at the terminal. Type `approve` to accept it as-is, or type revision feedback and the draft will be revised incorporating your notes. ## Files diff --git a/langgraph_plugin/graph_api/human_in_the_loop/workflow.py b/langgraph_plugin/graph_api/human_in_the_loop/workflow.py index d9e39911a..c407758e3 100644 --- a/langgraph_plugin/graph_api/human_in_the_loop/workflow.py +++ b/langgraph_plugin/graph_api/human_in_the_loop/workflow.py @@ -6,7 +6,6 @@ from datetime import timedelta -from langchain.chat_models import init_chat_model from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import START, StateGraph @@ -21,25 +20,21 @@ class State(TypedDict): async def generate_draft(state: State) -> dict[str, str]: - """Generate a draft response with an LLM.""" - response = await init_chat_model("claude-sonnet-4-6").ainvoke( - f"Please respond concisely to: {state['value']}" - ) - return {"value": str(response.content)} + """Generate a draft response. Replace with an LLM call in production.""" + return { + "value": ( + f"Here's my response to '{state['value']}': " + "The answer is 42. Let me know if this helps!" + ) + } async def human_review(state: State) -> dict[str, str]: - """Present draft to human for review via interrupt; revise with LLM on feedback.""" + """Present draft to human for review via interrupt; revise on feedback.""" feedback = interrupt(state["value"]) if feedback == "approve": return {"value": state["value"]} - response = await init_chat_model("claude-sonnet-4-6").ainvoke( - "Revise the following draft according to the reviewer's feedback. " - "Output only the revised draft, with no preamble.\n\n" - f"Draft:\n{state['value']}\n\n" - f"Feedback:\n{feedback}" - ) - return {"value": str(response.content)} + return {"value": f"[Revised] {state['value']} (incorporating feedback: {feedback})"} def make_chatbot_graph() -> StateGraph: diff --git a/tests/langgraph_plugin/functional_human_in_the_loop_test.py b/tests/langgraph_plugin/functional_human_in_the_loop_test.py index 2e1d0dfdd..3d0b1fc7b 100644 --- a/tests/langgraph_plugin/functional_human_in_the_loop_test.py +++ b/tests/langgraph_plugin/functional_human_in_the_loop_test.py @@ -1,7 +1,6 @@ import asyncio import sys import uuid -from unittest.mock import patch import pytest from temporalio.client import Client @@ -21,26 +20,14 @@ ) -class _FakeMessage: - def __init__(self, content: str) -> None: - self.content = content - - -class _EchoModel: - """Stand-in for a chat model that echoes the prompt back as its response.""" - - async def ainvoke(self, prompt: str) -> _FakeMessage: - return _FakeMessage(prompt) - - -def _fake_init_chat_model(*args: object, **kwargs: object) -> _EchoModel: - return _EchoModel() - - -_patch_llm = lambda: patch( - "langgraph_plugin.functional_api.human_in_the_loop.workflow.init_chat_model", - _fake_init_chat_model, -) +@pytest.fixture(autouse=True) +def _restore_tasks(): + original_funcs = [t.func for t in all_tasks] + try: + yield + finally: + for t, orig in zip(all_tasks, original_funcs): + t.func = orig async def test_functional_human_in_the_loop_approve(client: Client) -> None: @@ -51,33 +38,32 @@ async def test_functional_human_in_the_loop_approve(client: Client) -> None: activity_options=activity_options, ) - with _patch_llm(): - async with Worker( - client, + async with Worker( + client, + task_queue=task_queue, + workflows=[ChatbotFunctionalWorkflow], + plugins=[plugin], + ): + handle = await client.start_workflow( + ChatbotFunctionalWorkflow.run, + "test message", + id=f"functional-hitl-{uuid.uuid4()}", task_queue=task_queue, - workflows=[ChatbotFunctionalWorkflow], - plugins=[plugin], - ): - handle = await client.start_workflow( - ChatbotFunctionalWorkflow.run, - "test message", - id=f"functional-hitl-{uuid.uuid4()}", - task_queue=task_queue, - ) - - # Poll for draft to be ready - draft = None - for _ in range(40): - await asyncio.sleep(0.25) - draft = await handle.query(ChatbotFunctionalWorkflow.get_draft) - if draft is not None: - break - assert draft is not None - assert "test message" in draft - - # Approve - await handle.signal(ChatbotFunctionalWorkflow.provide_feedback, "approve") - result = await handle.result() + ) + + # Poll for draft to be ready + draft = None + for _ in range(40): + await asyncio.sleep(0.25) + draft = await handle.query(ChatbotFunctionalWorkflow.get_draft) + if draft is not None: + break + assert draft is not None + assert "test message" in draft + + # Approve + await handle.signal(ChatbotFunctionalWorkflow.provide_feedback, "approve") + result = await handle.result() assert result["response"] == draft @@ -90,36 +76,33 @@ async def test_functional_human_in_the_loop_revise(client: Client) -> None: activity_options=activity_options, ) - with _patch_llm(): - async with Worker( - client, + async with Worker( + client, + task_queue=task_queue, + workflows=[ChatbotFunctionalWorkflow], + plugins=[plugin], + ): + handle = await client.start_workflow( + ChatbotFunctionalWorkflow.run, + "test message", + id=f"functional-hitl-revise-{uuid.uuid4()}", task_queue=task_queue, - workflows=[ChatbotFunctionalWorkflow], - plugins=[plugin], - ): - handle = await client.start_workflow( - ChatbotFunctionalWorkflow.run, - "test message", - id=f"functional-hitl-revise-{uuid.uuid4()}", - task_queue=task_queue, - ) - - # Poll for draft - draft = None - for _ in range(40): - await asyncio.sleep(0.25) - draft = await handle.query(ChatbotFunctionalWorkflow.get_draft) - if draft is not None: - break - assert draft is not None - - # Send revision feedback - await handle.signal( - ChatbotFunctionalWorkflow.provide_feedback, "please be more concise" - ) - result = await handle.result() - - # The revision task feeds the draft and feedback into the LLM; the echo - # stand-in returns the revision prompt, which contains both. + ) + + # Poll for draft + draft = None + for _ in range(40): + await asyncio.sleep(0.25) + draft = await handle.query(ChatbotFunctionalWorkflow.get_draft) + if draft is not None: + break + assert draft is not None + + # Send revision feedback + await handle.signal( + ChatbotFunctionalWorkflow.provide_feedback, "please be more concise" + ) + result = await handle.result() + + assert "[Revised]" in result["response"] assert "please be more concise" in result["response"] - assert "test message" in result["response"] diff --git a/tests/langgraph_plugin/human_in_the_loop_test.py b/tests/langgraph_plugin/human_in_the_loop_test.py index 2d0b917f2..ad81e492c 100644 --- a/tests/langgraph_plugin/human_in_the_loop_test.py +++ b/tests/langgraph_plugin/human_in_the_loop_test.py @@ -1,7 +1,6 @@ import asyncio import sys import uuid -from unittest.mock import patch import pytest from temporalio.client import Client @@ -19,59 +18,36 @@ ) -class _FakeMessage: - def __init__(self, content: str) -> None: - self.content = content - - -class _EchoModel: - """Stand-in for a chat model that echoes the prompt back as its response.""" - - async def ainvoke(self, prompt: str) -> _FakeMessage: - return _FakeMessage(prompt) - - -def _fake_init_chat_model(*args: object, **kwargs: object) -> _EchoModel: - return _EchoModel() - - -_patch_llm = lambda: patch( - "langgraph_plugin.graph_api.human_in_the_loop.workflow.init_chat_model", - _fake_init_chat_model, -) - - async def test_human_in_the_loop_approve(client: Client) -> None: task_queue = f"hitl-test-{uuid.uuid4()}" plugin = LangGraphPlugin(graphs={"chatbot": make_chatbot_graph()}) - with _patch_llm(): - async with Worker( - client, + async with Worker( + client, + task_queue=task_queue, + workflows=[ChatbotWorkflow], + plugins=[plugin], + ): + handle = await client.start_workflow( + ChatbotWorkflow.run, + "test message", + id=f"hitl-{uuid.uuid4()}", task_queue=task_queue, - workflows=[ChatbotWorkflow], - plugins=[plugin], - ): - handle = await client.start_workflow( - ChatbotWorkflow.run, - "test message", - id=f"hitl-{uuid.uuid4()}", - task_queue=task_queue, - ) - - # Poll for draft to be ready - draft = None - for _ in range(40): - await asyncio.sleep(0.25) - draft = await handle.query(ChatbotWorkflow.get_draft) - if draft is not None: - break - assert draft is not None - assert "test message" in draft - - # Approve - await handle.signal(ChatbotWorkflow.provide_feedback, "approve") - result = await handle.result() + ) + + # Poll for draft to be ready + draft = None + for _ in range(40): + await asyncio.sleep(0.25) + draft = await handle.query(ChatbotWorkflow.get_draft) + if draft is not None: + break + assert draft is not None + assert "test message" in draft + + # Approve + await handle.signal(ChatbotWorkflow.provide_feedback, "approve") + result = await handle.result() assert result == draft # approved draft returned as-is @@ -80,36 +56,31 @@ async def test_human_in_the_loop_revise(client: Client) -> None: task_queue = f"hitl-revise-test-{uuid.uuid4()}" plugin = LangGraphPlugin(graphs={"chatbot": make_chatbot_graph()}) - with _patch_llm(): - async with Worker( - client, + async with Worker( + client, + task_queue=task_queue, + workflows=[ChatbotWorkflow], + plugins=[plugin], + ): + handle = await client.start_workflow( + ChatbotWorkflow.run, + "test message", + id=f"hitl-revise-{uuid.uuid4()}", task_queue=task_queue, - workflows=[ChatbotWorkflow], - plugins=[plugin], - ): - handle = await client.start_workflow( - ChatbotWorkflow.run, - "test message", - id=f"hitl-revise-{uuid.uuid4()}", - task_queue=task_queue, - ) - - # Poll for draft - draft = None - for _ in range(40): - await asyncio.sleep(0.25) - draft = await handle.query(ChatbotWorkflow.get_draft) - if draft is not None: - break - assert draft is not None - - # Send revision feedback - await handle.signal( - ChatbotWorkflow.provide_feedback, "please be more concise" - ) - result = await handle.result() - - # The revision node feeds the draft and feedback into the LLM; the echo - # stand-in returns the revision prompt, which contains both. + ) + + # Poll for draft + draft = None + for _ in range(40): + await asyncio.sleep(0.25) + draft = await handle.query(ChatbotWorkflow.get_draft) + if draft is not None: + break + assert draft is not None + + # Send revision feedback + await handle.signal(ChatbotWorkflow.provide_feedback, "please be more concise") + result = await handle.result() + + assert "[Revised]" in result assert "please be more concise" in result - assert "test message" in result From 16813dd64b13e160de761cc12f781f1028dfc2dc Mon Sep 17 00:00:00 2001 From: Rashmi Date: Thu, 17 Sep 2026 02:19:17 +0530 Subject: [PATCH 3/3] Fix mypy attribute error in functional HITL test fixture --- tests/langgraph_plugin/functional_human_in_the_loop_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/langgraph_plugin/functional_human_in_the_loop_test.py b/tests/langgraph_plugin/functional_human_in_the_loop_test.py index 3d0b1fc7b..33b4677f5 100644 --- a/tests/langgraph_plugin/functional_human_in_the_loop_test.py +++ b/tests/langgraph_plugin/functional_human_in_the_loop_test.py @@ -22,12 +22,12 @@ @pytest.fixture(autouse=True) def _restore_tasks(): - original_funcs = [t.func for t in all_tasks] + original_funcs = [getattr(t, "func") for t in all_tasks] try: yield finally: for t, orig in zip(all_tasks, original_funcs): - t.func = orig + setattr(t, "func", orig) async def test_functional_human_in_the_loop_approve(client: Client) -> None: