Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
version: 1

sample:
owner: jiahuigu
experiences: [azd, vscode]

tests:
- name: approved-tasks-use-latest-user-message
turns:
- input: Draft a one-sentence launch email containing the exact marker SEATTLE-LAUNCH-071. Do not include any other city or event marker.
approvals:
mcp:
- server_label: langgraph
name: __hosted_agent_adapter_interrupt__
assertions:
- source: assistant_text
type: contains
value: SEATTLE-LAUNCH-071
- input: This is a new task. Draft a one-sentence invitation containing the exact marker PARIS-EVENT-072. Do not mention the previous task or its marker.
approvals:
mcp:
- server_label: langgraph
name: __hosted_agent_adapter_interrupt__
assertions:
- source: assistant_text
type: contains
value: PARIS-EVENT-072
- source: assistant_text
type: contains
value: SEATTLE-LAUNCH-071
min_matches: 0
max_matches: 0
177 changes: 177 additions & 0 deletions .github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Local sample lifecycle regressions using runtime dependencies, not Azure access.

Run from the repository root in the sample's frozen runtime environment:
python .github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py
"""

from __future__ import annotations

import importlib.util
import json
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch

from langchain_azure_ai.agents.hosting import ResponsesHostServer
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from starlette.testclient import TestClient

ROOT = Path(__file__).resolve().parents[3]
SAMPLE = ROOT / (
"samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/"
"src/langgraph-human-in-the-loop-responses/main.py"
)


class LangGraphSampleLifecycleTests(unittest.TestCase):
def test_revision_then_new_task_isolates_model_context(self) -> None:
state_root = self.enterContext(tempfile.TemporaryDirectory())
spec = importlib.util.spec_from_file_location("hitl_sample_lifecycle", SAMPLE)
self.assertIsNotNone(spec)
self.assertIsNotNone(spec.loader)
module = importlib.util.module_from_spec(spec)
model = types.SimpleNamespace(
ainvoke=AsyncMock(
side_effect=[
AIMessage(content="SEATTLE-DRAFT-ORIGINAL"),
AIMessage(content="SEATTLE-DRAFT-REVISED"),
AIMessage(content="PARIS-DRAFT"),
]
)
)
with (
patch.dict(sys.modules, {module.__name__: module}),
patch.dict(
os.environ,
{"AGENTSERVER_STATE_ROOT": state_root, "OTEL_SDK_DISABLED": "true"},
),
patch("dotenv.load_dotenv"),
):
spec.loader.exec_module(module)
with patch.object(
module, "FoundryCheckpointSaver", return_value=InMemorySaver()
):
graph = module._build_graph(model)
with TestClient(ResponsesHostServer(graph).app) as client:
conversation = {"id": "hitl-lifecycle"}

def post(input_items: object) -> dict:
response = client.post(
"/responses",
json={"conversation": conversation, "input": input_items},
)
self.assertEqual(response.status_code, 200, response.text)
payload = response.json()
self.assertEqual(payload["status"], "completed", payload)
return payload

def pending(payload: dict) -> dict:
requests = [
item
for item in payload["output"]
if item.get("type") == "mcp_approval_request"
]
self.assertEqual(len(requests), 1, payload)
return requests[0]

def approve(payload: dict) -> dict:
return post(
[
{
"type": "mcp_approval_response",
"approval_request_id": pending(payload)["id"],
"approve": True,
}
]
)

def assistant_text(payload: dict) -> str:
return "".join(
part.get("text", "")
for item in payload["output"]
if item.get("type") == "message"
for part in item.get("content", [])
)

initial = post("Draft a Seattle launch email.")
pending(initial)
original_call = model.ainvoke.await_args_list[0]
self.assertEqual(
original_call.args[0][1].content, "Draft a Seattle launch email."
)
sentinels = [
item
for item in initial["output"]
if item.get("type") == "function_call"
and item.get("name") == "__hosted_agent_adapter_interrupt__"
]
self.assertEqual(len(sentinels), 1, initial)
feedback = "Keep the Seattle launch date, but shorten the email."
revised = post(
[
{
"type": "function_call_output",
"call_id": sentinels[0]["call_id"],
"output": json.dumps({"resume": {"feedback": feedback}}),
}
]
)
self.assertEqual(
json.loads(pending(revised)["arguments"])["value"]["draft"],
"SEATTLE-DRAFT-REVISED",
)
self.assertEqual(model.ainvoke.await_count, 2)
revision_messages = model.ainvoke.await_args_list[1].args[0]
self.assertEqual(
[message.content for message in revision_messages[1:]],
[
"Draft a Seattle launch email.",
"SEATTLE-DRAFT-ORIGINAL",
f"Revision feedback: {feedback}",
],
"revision must apply the current draft and feedback",
)
self.assertIsInstance(revision_messages[2], AIMessage)
self.assertIsInstance(revision_messages[3], HumanMessage)
approved = approve(revised)
self.assertFalse(
any(
item.get("type") == "mcp_approval_request"
for item in approved["output"]
),
approved,
)
self.assertEqual(model.ainvoke.await_count, 2)
self.assertEqual(assistant_text(approved), "SEATTLE-DRAFT-REVISED")

following = post("Draft a Paris event invitation.")
self.assertEqual(model.ainvoke.await_count, 3)
following_messages = model.ainvoke.await_args_list[2].args[0]
self.assertEqual(
[message.content for message in following_messages[1:]],
["Draft a Paris event invitation."],
"new task must exclude the previous draft and revision feedback",
)
self.assertEqual(
json.loads(pending(following)["arguments"])["value"]["draft"],
"PARIS-DRAFT",
)
final = approve(following)
self.assertEqual(model.ainvoke.await_count, 3)
self.assertEqual(assistant_text(final), "PARIS-DRAFT")
self.assertFalse(
any(
item.get("type") == "mcp_approval_request"
for item in final["output"]
),
final,
)


if __name__ == "__main__":
unittest.main()
32 changes: 32 additions & 0 deletions .github/workflows/hosted-agent-runtime-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Hosted-agent runtime regressions

on:
workflow_dispatch:
pull_request:
paths:
- 'samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/src/**'
- '.github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py'
- '.github/workflows/hosted-agent-runtime-tests.yml'

permissions:
contents: read

jobs:
hitl-lifecycle:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
RUNTIME_PROJECT: samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/src/langgraph-human-in-the-loop-responses
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.13'
- name: Install runtime test resolver
run: python -m pip install --disable-pip-version-check "uv==0.11.7"
- name: Install frozen sample dependencies
run: uv sync --frozen --python 3.13 --project "$RUNTIME_PROJECT"
- name: Verify HITL revision lifecycle
run: uv run --no-sync --project "$RUNTIME_PROJECT" python .github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ A [LangGraph](https://langchain-ai.github.io/langgraph/) **human-in-the-loop** a
### Graph shape

```
START → draft → await_approval ──approve──► END (emit final draft)
│
└──revise(feedback)──► draft (loop)
│
└──reject──► host returns response.failed (interrupt_rejected)
START → start_task → draft → await_approval ──approve──► END (emit final draft)
│
└──revise(feedback)──► draft (loop)
│
└──reject──► host returns response.failed (interrupt_rejected)
```

The graph state declares three channels:
Expand All @@ -20,6 +20,12 @@ The graph state declares three channels:
- **`draft`** — the current proposal text under review.
- **`revision_history`** — list of `{draft, feedback}` pairs from prior revision rounds; each round seeds the next `draft` call with the rejected draft and the reviewer's feedback.

After completing a task, a new user message in the same conversation starts a new
task. `start_task` clears the previous draft and revision history, while `draft`
selects the latest user message rather than the first conversation message.
Revision feedback resumes the existing task directly and retains its revision
history; it does not pass through `start_task`.

Routing out of `await_approval` is done by returning a [`Command(goto=...)`](https://langchain-ai.github.io/langgraph/how-tos/command/) instead of static edges, so the same node can finalize, loop, or stay paused depending on the resume value.

State is persisted by `FoundryCheckpointSaver` and keyed by the `conversation.id` from the Responses request, so follow-up requests continue the paused run even after a process restart. The saver uses Foundry State Store when hosted and a local file-backed store during local development. See [main.py](src/langgraph-human-in-the-loop-responses/main.py) for the full implementation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,23 @@ class State(TypedDict):


def _build_graph(model: ChatOpenAI):
def start_task(state: State) -> dict:
return {"draft": "", "revision_history": []}

async def draft(state: State) -> dict:
task = state["messages"][0].content
task = next(
(
message
for message in reversed(state["messages"])
if isinstance(message, HumanMessage)
),
None,
)
if task is None:
raise ValueError("A user message is required to draft a proposal")
msgs: list[BaseMessage] = [
SystemMessage(content=_DRAFT_PROMPT),
HumanMessage(content=f"Task: {task}"),
HumanMessage(content=task.content),
]
for rev in state.get("revision_history", []):
msgs.append(AIMessage(content=rev["draft"]))
Expand Down Expand Up @@ -125,9 +137,11 @@ def await_approval(state: State) -> Command:
)

builder = StateGraph(State)
builder.add_node("start_task", start_task)
builder.add_node("draft", draft)
builder.add_node("await_approval", await_approval)
builder.add_edge(START, "draft")
builder.add_edge(START, "start_task")
builder.add_edge("start_task", "draft")
builder.add_edge("draft", "await_approval")
checkpointer = FoundryCheckpointSaver(user_isolation=True)
return builder.compile(checkpointer=checkpointer)
Expand Down
Loading