Skip to content

Troubleshooting & gotchas

Real failure modes we hit building the runnable examples, with the root cause and the fix. Scan this before you debug.

1. "An interrupt inside a sub-flow never resumes"

Symptom: a workflow with an Interrupt inside a SubFlow (or embedded Flow.add_flow) pauses once, but every resume pauses again immediately.

Root cause: when the interrupt fires, the pending resume-point stores the outer node id of the sub-flow (e.g. subflow_2). On resume the runtime re-enters the sub-flow from its entry — so it walks straight back into the same interrupt.

Fix: keep the Interrupt at the top level of the graph. Put the escalation chain in the outer graph (e.g. route(..., human=[LLM, Interrupt, AppendAssistant])) instead of nesting the interrupt inside the sub-flow.

2. "The supervisor never calls the model"

Symptom: a Supervisor routing node decides nothing / returns finish immediately.

Root cause: Supervisor only consults the model when there is a user message in the state's messages list.

Fix: seed the initial state with the user input:

state["messages"] = [{"role": "user", "content": ticket}]

3. "graph.run(state=...) rejects my TypedDict"

Symptom: run()'s state parameter is typed dict | State, and a TypedDict instance (TriageState) doesn't type-check.

Root cause: a TypedDict is a dict at runtime but not at the type level.

Fix: wrap it in the framework's typed-state wrapper, which also carries the per-key reducers (and then reducers= is ignored):

from teff.state import State

state = State(TriageState, dict(initial_state()))
result = await graph.run(state=state, checkpointer=cp, checkpoint_id="ticket-1")

4. "Resume works in-process but not across processes"

Symptom: a run paused on an interrupt resumes fine in one test, but a fresh process can't continue it.

Root cause: resume needs all three to match: the same checkpointer backend, the same checkpoint_id, and the same owner (defaults to "default").

Fix: persist checkpoint_id/owner with the session, and read the checkpoint with load(), not get():

cp = JSONFileCheckpointer("data/checkpoints")
saved = cp.load(owner="default", checkpoint_id="ticket-1")  # None if absent

5. "State I mutated before the interrupt is gone after resume"

Symptom: assertions on state set before GraphInterrupt fail after resume.

Root cause: on interrupt, the local state dict you hold is a copy; the real state is the checkpoint. The pre-interrupt object is not updated.

Fix: read post-resume values from the resumed result, not the pre-interrupt dict:

try:
    result = await graph.run(...)
except GraphInterrupt:
    ...
    result = await graph.run(..., resume={"escalate": answer})
assert result["human_note"] == "..."  # read from the resumed run

6. "Tests fail only after uv sync without extras"

Symptom: a handful of tests fail locally but pass in CI.

Root cause: teff's heavy extras (e.g. chromadb, pydantic_settings) are optional; plain uv sync skips them.

Fix:

uv sync --all-extras

7. "ruff format --check . fails on my Markdown"

Symptom: CI's format job fails, but no .py file changed.

Root cause: ruff formats Python blocks inside Markdown too, and CI formats the whole repo (ruff format --check .).

Fix:

uv run ruff format docs/recipes/*.md

8. Offline testing without a model

To test flows that call an LLM with no network, patch the httpx transport and fabricate the responses the provider expects — both the plain post and the stream path (the streaming call passes "POST" as its first argument and returns a context manager with aiter_lines). See the mocked transport in examples/recipes/support_triage/tests/test_support_triage.py.