Test with ScriptedChat¶
ScriptedChat plays the chat model in tests: it returns the responses you script, in order, and records every request.
When you need it¶
Use it for unit tests of graphs and agents that call a model. The tests need no network and no API key.
Steps¶
- Build a
ScriptedChatwith the model's responses in order. Adddefault=to answer any request after them. - Build the agent or graph with it.
- Run it.
- Assert on the answer, on
chat.requests, and onchat.responses == [].
Complete example¶
Script the tool call and the answer, run the agent, then check the answer, the tool calls and the requests:
import asyncio
from nodestep import ScriptedChat, build_react_agent, run_agent, tool
from nodestep.chat import ChatResponse, ToolCall, ToolMessage
looked_up: list[str] = []
@tool
def get_order_status(order_id: str) -> str:
"""Look up the shipping status of an order."""
looked_up.append(order_id)
return "shipped"
chat = ScriptedChat(
[
ChatResponse(
tool_calls=[
ToolCall(
id="call_1",
name="get_order_status",
arguments={"order_id": "A-1001"},
)
]
),
"Order A-1001 has shipped.",
]
)
agent = build_react_agent(
chat,
tools=[get_order_status],
system_prompt="You answer questions about orders.",
)
answer = asyncio.run(run_agent(agent, "Where is order A-1001?"))
assert answer == "Order A-1001 has shipped."
assert looked_up == ["A-1001"]
assert chat.responses == []
first, second = chat.requests
assert first.messages[0].content == "You answer questions about orders."
assert [definition.name for definition in first.tools] == ["get_order_status"]
tool_result = second.messages[-1]
assert isinstance(tool_result, ToolMessage)
assert tool_result.content == '{"result":"shipped"}'
print("passed")
passed
In pytest, the same lines go into a test function. With pytest-asyncio, write an async def test and await run_agent(...).
Test interrupts and approvals¶
Interrupts need a state store; in tests, InMemoryStateStore keeps the threads in memory. Pause for approval shows the steps. This test denies one call and approves another:
import asyncio
from nodestep import (
Graph,
InMemoryStateStore,
Resume,
ScriptedChat,
build_react_agent,
tool,
)
from nodestep.chat import ChatResponse, HumanMessage, ToolCall
from nodestep.middleware import InterruptRule, ToolInterrupt, ToolInterruptMiddleware
sent: list[str] = []
@tool
def send_email(to: str, body: str) -> str:
"""Send an email."""
sent.append(to)
return "sent"
def make_agent() -> Graph:
chat = ScriptedChat(
[
ChatResponse(
tool_calls=[
ToolCall(
id="call_1",
name="send_email",
arguments={"to": "ada@example.com", "body": "Hi"},
)
]
),
"Done.",
]
)
return build_react_agent(
chat,
tools=[send_email],
middleware=[ToolInterruptMiddleware(rules=[InterruptRule(tool="send_email")])],
state_store=InMemoryStateStore(),
)
async def main() -> None:
agent = make_agent()
message = {"messages": [HumanMessage(content="Email Ada")]}
paused = await agent.ainvoke(message, thread_id="t1")
assert paused.status == "interrupted"
(pending,) = paused.interrupts.values()
assert isinstance(pending.payload, ToolInterrupt)
assert pending.payload.arguments == {"to": "ada@example.com", "body": "Hi"}
assert sent == []
denied = await agent.ainvoke(None, thread_id="t1", resume=Resume(False))
assert denied.status == "completed"
assert sent == []
assert "denied" in denied.state.messages[2].content
agent = make_agent()
await agent.ainvoke(message, thread_id="t2")
await agent.ainvoke(None, thread_id="t2", resume=Resume(True))
assert sent == ["ada@example.com"]
print("passed")
asyncio.run(main())
passed
Good to know¶
- A
stritem meansChatResponse(content=...); aChatResponsecan carrytool_calls,refusal,finish_reason,usageandmodel, as inChatResponse(content="ok", usage={"input_tokens": 10, "output_tokens": 2}). - Each request takes the next response, in order. When the script runs out,
default=answers every further request. - Without
default=, the next request raisesModelProviderError("ScriptedChat has no response left for request N"), so an unexpected model call fails the test. chat.requestsrecords everyChatRequest: the messages, the tools offered,tool_choiceand the output schema. Assert on it to check prompts, offered tools and the history the model saw.chat.responsesholds the responses not used yet; an empty list means the script was used up exactly.stream()yields the response in word chunks, then the tool calls, the finish reason and the usage, so a graph withstream=Trueis tested the same way.- Build a new
ScriptedChatand graph for every test, so scripts and stores do not leak between tests. - Pass fakes through
context=instead of patching: a fake database or client that nodes and tools read fromctx.context. - A graph without a model needs no
ScriptedChat. Run it through the graph, so the flow, the validation and the reducers are tested too. Assert on the"updates"events, which show what each node returned; see Streaming.