deer-flow/backend/tests/test_goal_worker.py
DanielWalnut 25ea6970a6
feat(runtime): implement goal continuations (#3858)
* implement goal continuations

* fix(goal): address review findings for goal continuations

- goal: key the no-progress breaker on a signature of the latest visible
  assistant evidence instead of the evaluator's volatile free-text, so it
  actually fires on stalled turns; thread the signature through every
  worker persist / no-progress call site
- goal: align _stand_down_reason default caps with should_continue_goal
  (8 / 2) so the two gate functions agree on goals missing the fields
- runtime: offload the synchronous checkpointer fallback via
  asyncio.to_thread (goal.py + worker.py) to keep blocking IO off the loop
- frontend: i18n the GoalStatus "Goal" label (goalLabel in en/zh/types)
- frontend: extract pure composer helpers into input-box-helpers.ts with
  unit tests (parseGoalCommand, readGoalResponseError, skill suggestions)
- tests: cover the evidence-based no-progress and default-cap behavior
- docs: align backend/AGENTS.md goal paragraph with actual behavior
- e2e: prettier-format chat.spec.ts (fixes the lint-frontend CI failure)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(frontend): hide goal continuation counter until the agent continues

The goal status bar rendered a raw "0/8" before any auto-continuation, which
read as a mysterious score. Now the counter is hidden until
continuation_count > 0, then shows "Continuing N/M" with a tooltip explaining
the auto-continuation cap.

- Extract getGoalContinuationDisplay into a pure helper (hides at 0) + unit tests
- Add goalContinuing / goalContinuationTooltip i18n keys (en/zh/types)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(goal): address review findings for goal continuations

Frontend correctness
- Fix the optimistic /goal result permanently shadowing server goal state:
  the streamed continuation counter never surfaced for a goal set in-session.
  Extract a shared useActiveGoal hook (used by both chat pages) that reconciles
  the optimistic copy with server state via a goalReconciliationKey, de-duping
  the copy-pasted goal block across the two pages.
- Stop /goal status|clear failures from escaping handleSubmit as unhandled
  rejections (handleGoalCommand now returns success; the run only starts when a
  goal was actually saved).
- Use a function replacer for the goal-status toast so an objective containing
  $&/$1 isn't treated as a replacement pattern.

Backend cleanliness / correctness
- De-duplicate four byte-identical helpers (_call_checkpointer_method,
  _message_type, _additional_kwargs, _is_visible_message) by importing them
  from runtime.goal instead of re-defining them in the run worker.
- Remove the dead `checkpoint_tuple.tasks` durability guard (CheckpointTuple has
  no tasks field) and document that pending_writes is the durability signal.
- Decompose the 176-line _prepare_goal_continuation_input: extract
  _reread_goal_and_checkpoint and a _persist closure so the thread-unchanged
  guard and stand-down persistence aren't open-coded three times. Document the
  last-writer-wins write-window limitation as a follow-up.
- Add a shared parse_goal_command helper and use it from the TUI and IM-channel
  /goal handlers (one place for the status/clear/set semantics).

Tests
- Restore the 11 command-registry tests dropped by the previous goal change
  (filter_commands ranking/description, build_registry builtins/skills, resolve
  cases) alongside the new goal tests.
- Add coverage for the IM-channel _handle_goal_command, the TUI _handle_goal
  handler, parse_goal_command, and goalReconciliationKey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix goal review feedback

* fix goal continuation checkpoint races

* prioritize goal commands while streaming

Route composer submits through a shared helper so /goal commands can be handled before the streaming stop shortcut, while ordinary streaming submits still stop the active run.

Testing: cd frontend && pnpm exec rstest run tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; cd frontend && pnpm check

* preserve goal status during clarification

Keep omitted stream goal fields distinct from explicit null clears so clarification interrupts do not hide an active thread goal that is still present in the checkpoint.

Testing: pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check

* style: format active goal hook

Run Prettier on use-active-goal.ts to satisfy the frontend lint workflow formatting gate.

Testing: pnpm format; pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check

* fix goal review followups

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:49:33 +08:00

689 lines
24 KiB
Python

import asyncio
import copy
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.base import empty_checkpoint, uuid6
from langgraph.checkpoint.memory import InMemorySaver
from deerflow.runtime.goal import GoalEvaluation, attach_goal_evaluation, build_goal_state, latest_visible_assistant_signature, read_thread_goal, write_thread_goal
from deerflow.runtime.runs import worker
from deerflow.runtime.runs.manager import RunRecord
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
class _CollectingBridge:
def __init__(self) -> None:
self.events: list[tuple[str, object]] = []
async def publish(self, _run_id: str, event: str, payload: object) -> None:
self.events.append((event, payload))
class _ClearBeforeSecondGoalReadCheckpointer:
"""Wrap a saver and clear the goal just before the evaluator write rereads.
The first ``aget_tuple`` is the evaluator's current-goal read. The second is
``write_thread_goal`` preparing its read-modify-write. Clearing at that point
models a user ``/goal clear`` landing between those two operations.
"""
def __init__(self, inner: InMemorySaver, thread_id: str) -> None:
self.inner = inner
self.thread_id = thread_id
self.read_count = 0
self.cleared = False
def get_next_version(self, current, channel):
return self.inner.get_next_version(current, channel)
async def aget_tuple(self, config):
self.read_count += 1
if self.read_count == 2 and not self.cleared:
self.cleared = True
await write_thread_goal(self.inner, self.thread_id, None, as_node="test_clear")
return await self.inner.aget_tuple(config)
async def aput(self, *args, **kwargs):
return await self.inner.aput(*args, **kwargs)
async def _seed_goal_thread(
checkpointer: InMemorySaver,
*,
thread_id: str,
goal_text: str,
messages: list | None = None,
) -> None:
checkpoint = empty_checkpoint()
checkpoint["channel_values"] = {
"messages": messages
or [
HumanMessage(content="Please finish this task."),
AIMessage(content="I made a start, but I am not done."),
]
}
checkpoint["channel_versions"] = {"messages": 1}
checkpointer.put(
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
checkpoint,
{"step": 1},
{"messages": 1},
)
await write_thread_goal(checkpointer, thread_id, build_goal_state(goal_text, max_continuations=2))
async def _write_messages(checkpointer: InMemorySaver, *, thread_id: str, messages: list) -> None:
checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}})
assert checkpoint_tuple is not None
checkpoint = copy.deepcopy(getattr(checkpoint_tuple, "checkpoint", {}) or {})
metadata = copy.deepcopy(getattr(checkpoint_tuple, "metadata", {}) or {})
channel_values = dict(checkpoint.get("channel_values", {}) or {})
channel_values["messages"] = messages
checkpoint["channel_values"] = channel_values
channel_versions = dict(checkpoint.get("channel_versions", {}) or {})
current_version = channel_versions.get("messages")
channel_versions["messages"] = checkpointer.get_next_version(current_version, None)
checkpoint["channel_versions"] = channel_versions
checkpoint["id"] = str(uuid6())
metadata["step"] = metadata.get("step", 0) + 1
metadata["writes"] = {"test": {"messages": messages}}
await checkpointer.aput(
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
checkpoint,
metadata,
{"messages": channel_versions["messages"]},
)
@pytest.mark.asyncio
async def test_goal_worker_returns_hidden_continuation_when_goal_is_unmet(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "goal-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(goal, messages, **_kwargs):
assert goal["objective"] == "Finish all tests"
assert [message.content for message in messages][-1] == "I made a start, but I am not done."
return GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="Tests have not passed yet.",
evidence_summary="Implementation is incomplete.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-1",
model_name="test-model",
app_config=None,
)
assert continuation is not None
[message] = continuation["messages"]
assert message.additional_kwargs["hide_from_ui"] is True
assert "Finish all tests" in message.content
assert "Tests have not passed yet." in message.content
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["continuation_count"] == 1
assert latest_goal["last_evaluation"]["run_id"] == "run-1"
assert latest_goal["last_evaluation"]["blocker"] == "goal_not_met_yet"
assert "stand_down_reason" not in latest_goal["last_evaluation"]
assert bridge.events[0][0] == "values"
@pytest.mark.asyncio
async def test_goal_worker_clears_goal_when_evaluator_is_satisfied(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "done-goal-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
return GoalEvaluation(
satisfied=True,
blocker="none",
reason="The visible conversation says the task is done.",
evidence_summary="Done.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-2",
model_name="test-model",
app_config=None,
)
assert continuation is None
assert await read_thread_goal(checkpointer, thread_id) is None
assert bridge.events[0][0] == "values"
@pytest.mark.asyncio
async def test_goal_worker_stands_down_for_non_continuable_blocker(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "blocked-goal-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
return GoalEvaluation(
satisfied=False,
blocker="missing_evidence",
reason="The transcript does not prove any verification.",
evidence_summary="No test result is visible.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-3",
model_name="test-model",
app_config=None,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["continuation_count"] == 0
assert latest_goal["last_evaluation"]["blocker"] == "missing_evidence"
assert latest_goal["last_evaluation"]["stand_down_reason"] == "blocked:missing_evidence"
@pytest.mark.asyncio
async def test_goal_worker_stands_down_when_no_progress_repeats(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "no-progress-goal-thread"
messages = [HumanMessage(content="Please finish this task."), AIMessage(content="I made a start, but I am not done.")]
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests", messages=messages)
previous_goal = await read_thread_goal(checkpointer, thread_id)
assert previous_goal is not None
repeated_evaluation = GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="The same work remains.",
evidence_summary="No new verification evidence.",
)
# Seed the prior evaluation with the SAME visible assistant evidence the worker
# will recompute, so the no-progress breaker recognises the stalled turn even
# though the evaluator may reword its free-text reason.
evidence_signature = latest_visible_assistant_signature(messages)
await write_thread_goal(
checkpointer,
thread_id,
attach_goal_evaluation(previous_goal, repeated_evaluation, run_id="previous-run", no_progress_count=1, evidence_signature=evidence_signature),
)
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
return repeated_evaluation
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-4",
model_name="test-model",
app_config=None,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["no_progress_count"] == 2
assert latest_goal["last_evaluation"]["stand_down_reason"] == "no_progress_detected"
@pytest.mark.asyncio
async def test_goal_worker_does_not_resurrect_goal_cleared_during_evaluation(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "clear-during-eval-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
await write_thread_goal(checkpointer, thread_id, None, as_node="test")
return GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="More work remains.",
evidence_summary="Work remains.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-5",
model_name="test-model",
app_config=None,
)
assert continuation is None
assert await read_thread_goal(checkpointer, thread_id) is None
@pytest.mark.asyncio
async def test_goal_worker_does_not_resurrect_goal_cleared_during_persist():
checkpointer = InMemorySaver()
thread_id = "clear-during-persist-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
existing_goal = await read_thread_goal(checkpointer, thread_id)
assert existing_goal is not None
wrapped_checkpointer = _ClearBeforeSecondGoalReadCheckpointer(checkpointer, thread_id)
bridge = _CollectingBridge()
result = await worker._persist_goal_evaluation(
bridge=bridge,
checkpointer=wrapped_checkpointer,
thread_id=thread_id,
run_id="run-clear-during-persist",
goal=existing_goal,
evaluation=GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="More work remains.",
evidence_summary="Work remains.",
),
no_progress_count=0,
)
assert result is None
assert wrapped_checkpointer.cleared is True
assert await read_thread_goal(checkpointer, thread_id) is None
@pytest.mark.asyncio
async def test_goal_worker_stops_when_abort_is_requested_during_evaluation(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "abort-during-eval-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
abort_event = asyncio.Event()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
abort_event.set()
return GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="More work remains.",
evidence_summary="Work remains.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-abort",
model_name="test-model",
app_config=None,
abort_event=abort_event,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["continuation_count"] == 0
assert "last_evaluation" not in latest_goal
@pytest.mark.asyncio
async def test_goal_worker_stands_down_when_thread_changes_after_evaluation(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "user-wins-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, messages, **_kwargs):
await _write_messages(
checkpointer,
thread_id=thread_id,
messages=[*messages, HumanMessage(content="Actually, stop and wait.")],
)
return GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="More work remains.",
evidence_summary="Work remains.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-6",
model_name="test-model",
app_config=None,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["continuation_count"] == 0
assert latest_goal["last_evaluation"]["stand_down_reason"] == "thread_changed_after_evaluation"
@pytest.mark.asyncio
async def test_goal_worker_stands_down_without_durable_assistant_receipt():
checkpointer = InMemorySaver()
thread_id = "no-receipt-thread"
await _seed_goal_thread(
checkpointer,
thread_id=thread_id,
goal_text="Finish all tests",
messages=[HumanMessage(content="Please finish this task.")],
)
bridge = _CollectingBridge()
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-7",
model_name="test-model",
app_config=None,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["last_evaluation"]["blocker"] == "run_failed"
assert latest_goal["last_evaluation"]["stand_down_reason"] == "no_durable_end_of_turn"
def test_stand_down_reason_uses_documented_default_caps_when_missing():
"""_stand_down_reason must fall back to the same default caps as
should_continue_goal (8 / 2). A bare goal dict missing the cap fields must
not be reported as 'max reached' / 'no progress' when it has not actually
exhausted the documented defaults.
"""
bare_goal = {"objective": "x", "status": "active", "continuation_count": 0}
unmet = GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="", evidence_summary="")
assert worker._stand_down_reason(bare_goal, unmet, no_progress_count=0) is None
# And the two gate functions agree on the same bare goal.
from deerflow.runtime.goal import should_continue_goal
assert should_continue_goal(bare_goal, unmet, no_progress_count=0) is True
@pytest.mark.asyncio
async def test_run_agent_does_not_stream_continuation_after_abort(monkeypatch):
class FakeAgent:
def __init__(self) -> None:
self.inputs = []
self.metadata = {}
self.checkpointer = None
self.store = None
self.interrupt_before_nodes = []
self.interrupt_after_nodes = []
def astream(self, input_payload, **_kwargs):
self.inputs.append(input_payload)
async def _gen():
yield {"messages": []}
return _gen()
class FakeRunManager:
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
async def update_model_name(self, *_args, **_kwargs):
return None
async def update_run_completion(self, *_args, **_kwargs):
return None
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
return None
async def set_finalizing(self, _run_id, finalizing):
record.finalizing = finalizing
class FakeBridge:
async def publish(self, *_args, **_kwargs):
return None
async def publish_end(self, *_args, **_kwargs):
return None
async def cleanup(self, *_args, **_kwargs):
return None
async def fake_prepare(**kwargs):
kwargs["abort_event"].set()
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
fake_agent = FakeAgent()
record = RunRecord(
run_id="run-abort-loop",
thread_id="thread-abort-loop",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
model_name="test-model",
)
record.abort_event = asyncio.Event()
await worker.run_agent(
FakeBridge(),
FakeRunManager(),
record,
ctx=worker.RunContext(checkpointer=None),
agent_factory=lambda config: fake_agent,
graph_input={"messages": [HumanMessage(content="start")]},
config={"configurable": {"thread_id": "thread-abort-loop"}},
)
assert len(fake_agent.inputs) == 1
assert fake_agent.inputs[0] == {"messages": [HumanMessage(content="start")]}
assert record.status == RunStatus.interrupted
@pytest.mark.asyncio
async def test_run_agent_reuses_goal_evaluator_model_for_goal_loop(monkeypatch):
class FakeAgent:
def __init__(self) -> None:
self.inputs = []
self.metadata = {}
self.checkpointer = None
self.store = None
self.interrupt_before_nodes = []
self.interrupt_after_nodes = []
def astream(self, input_payload, **_kwargs):
self.inputs.append(input_payload)
async def _gen():
yield {"messages": []}
return _gen()
class FakeRunManager:
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
async def update_model_name(self, *_args, **_kwargs):
return None
async def update_run_completion(self, *_args, **_kwargs):
return None
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
return None
async def set_finalizing(self, _run_id, finalizing):
record.finalizing = finalizing
class FakeBridge:
async def publish(self, *_args, **_kwargs):
return None
async def publish_end(self, *_args, **_kwargs):
return None
async def cleanup(self, *_args, **_kwargs):
return None
evaluator_model = object()
create_calls = []
def fake_create_goal_evaluator_model(**kwargs):
create_calls.append(kwargs)
return evaluator_model
prepare_models = []
async def fake_prepare(**kwargs):
prepare_models.append(kwargs["evaluator_model_factory"]())
if len(prepare_models) == 1:
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
return None
monkeypatch.setattr(worker, "create_goal_evaluator_model", fake_create_goal_evaluator_model)
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
fake_agent = FakeAgent()
record = RunRecord(
run_id="run-model-cache",
thread_id="thread-model-cache",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
model_name="test-model",
)
record.abort_event = asyncio.Event()
await worker.run_agent(
FakeBridge(),
FakeRunManager(),
record,
ctx=worker.RunContext(checkpointer=None, app_config=object()),
agent_factory=lambda config: fake_agent,
graph_input={"messages": [HumanMessage(content="start")]},
config={"configurable": {"thread_id": "thread-model-cache"}},
)
assert len(fake_agent.inputs) == 2
assert prepare_models == [evaluator_model, evaluator_model]
assert len(create_calls) == 1
assert create_calls[0]["model_name"] == "test-model"
assert record.status == RunStatus.success
@pytest.mark.asyncio
async def test_run_agent_strips_branch_checkpoint_for_goal_continuation(monkeypatch):
class FakeAgent:
def __init__(self) -> None:
self.calls = []
self.metadata = {}
self.checkpointer = None
self.store = None
self.interrupt_before_nodes = []
self.interrupt_after_nodes = []
def astream(self, input_payload, **kwargs):
configurable = dict(kwargs["config"].get("configurable", {}))
self.calls.append((input_payload, configurable))
async def _gen():
yield {"messages": []}
return _gen()
class FakeRunManager:
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
async def update_model_name(self, *_args, **_kwargs):
return None
async def update_run_completion(self, *_args, **_kwargs):
return None
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
return None
async def set_finalizing(self, _run_id, finalizing):
record.finalizing = finalizing
class FakeBridge:
async def publish(self, *_args, **_kwargs):
return None
async def publish_end(self, *_args, **_kwargs):
return None
async def cleanup(self, *_args, **_kwargs):
return None
async def fake_prepare(**_kwargs):
if len(fake_agent.calls) == 1:
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
return None
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
fake_agent = FakeAgent()
record = RunRecord(
run_id="run-branch-continuation",
thread_id="thread-branch-continuation",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
model_name="test-model",
)
record.abort_event = asyncio.Event()
await worker.run_agent(
FakeBridge(),
FakeRunManager(),
record,
ctx=worker.RunContext(checkpointer=None),
agent_factory=lambda config: fake_agent,
graph_input={"messages": [HumanMessage(content="start")]},
config={
"configurable": {
"thread_id": "thread-branch-continuation",
"checkpoint_ns": "branch",
"checkpoint_id": "old-checkpoint",
"checkpoint_map": {"": "old-checkpoint"},
}
},
)
assert len(fake_agent.calls) == 2
first_config = fake_agent.calls[0][1]
second_config = fake_agent.calls[1][1]
assert first_config["checkpoint_ns"] == "branch"
assert first_config["checkpoint_id"] == "old-checkpoint"
assert first_config["checkpoint_map"] == {"": "old-checkpoint"}
assert second_config["checkpoint_ns"] == ""
assert "checkpoint_id" not in second_config
assert "checkpoint_map" not in second_config
assert second_config["thread_id"] == "thread-branch-continuation"