mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
perf(code): speed up shutdown after Ctrl+C/Ctrl+D (#4351)
Interactive shutdown could spend several seconds in serial teardown waits after Ctrl+C/Ctrl+D. This tightens the `dcode` shutdown path by reducing the server SIGTERM wait (`_SHUTDOWN_TIMEOUT` 5s → 3s), sharing a single checkpoint-existence query across both teardown hints (the LangSmith link and the `dcode -r` resume hint) instead of running it twice, and giving the active agent worker a bounded cancellation window before the Textual app exits so an in-flight run can finish persisting its trace instead of being torn down mid-request. The teardown query runs whenever the session owns a thread. An earlier iteration skipped it when no model turn had completed (`request_count == 0`), but that proved unsafe: an interrupted first turn can persist a checkpoint before any usage metadata is recorded, which would silently drop the resume hint for a thread the user could still resume. Avoiding the duplicate query keeps the teardown cost to one event-loop spin-up regardless. The agent-worker handoff uses Textual's supported `Worker.wait()` API, wrapped in `asyncio.shield` so the bounded timeout cancels only the wait and never the worker's own cancellation handler. Made by [Open SWE](https://openswe.vercel.app/agents/a3ed8343-0530-8ee7-dec2-11da90162628) --------- Co-authored-by: Deep Agent <agent@deepagents.dev> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Mason Daugherty <mason@langchain.dev> Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
@@ -95,6 +95,7 @@ from deepagents_code.widgets.subagent_panel import SubagentPanel
|
||||
from deepagents_code.widgets.welcome import WelcomeBanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_GRACEFUL_EXIT_WAIT_SECONDS = 2.0
|
||||
_monotonic = time.monotonic
|
||||
|
||||
_DEFERRED_START_NOTICE = (
|
||||
@@ -2200,6 +2201,9 @@ class DeepAgentsApp(App):
|
||||
self._git_branch_refresh_task: asyncio.Task[None] | None = None
|
||||
"""Latest background git-branch refresh task, if one is running."""
|
||||
|
||||
self._graceful_exit_task: asyncio.Task[None] | None = None
|
||||
"""Fire-and-forget task for deferred exit after agent worker cancellation."""
|
||||
|
||||
self._last_typed_at: float | None = None
|
||||
"""Typing-aware approval deferral state."""
|
||||
|
||||
@@ -9418,6 +9422,15 @@ class DeepAgentsApp(App):
|
||||
return_code: Exit code (non-zero for errors).
|
||||
message: Optional message to display on exit.
|
||||
"""
|
||||
# A second exit() while a graceful exit is already pending means the
|
||||
# user is forcing the issue (e.g. mashing Ctrl+D/Ctrl+C). Tear down
|
||||
# immediately rather than arming another bounded wait — the first call
|
||||
# already ran cleanup and cancelled the worker, so re-running it would
|
||||
# only make the force-quit wait out another window.
|
||||
if self._graceful_exit_task is not None and not self._graceful_exit_task.done():
|
||||
super().exit(result=result, return_code=return_code, message=message)
|
||||
return
|
||||
|
||||
# Merge in-flight turn stats before any cleanup that might raise.
|
||||
# When the agent worker is cancelled (e.g. Ctrl+D during a pending tool
|
||||
# call), the worker's finally block will see _inflight_turn_stats is
|
||||
@@ -9479,7 +9492,72 @@ class DeepAgentsApp(App):
|
||||
exc_info=True,
|
||||
)
|
||||
restore_iterm_cursor_guide()
|
||||
super().exit(result=result, return_code=return_code, message=message)
|
||||
|
||||
# Defer super().exit() so the agent worker's cancellation handler
|
||||
# (which, for remote agents, sends a server-side run cancel, and in all
|
||||
# cases persists interrupt state) has a bounded window to complete
|
||||
# before the event loop is torn down. This gives the server a chance to
|
||||
# finish persisting the in-flight run's trace instead of being
|
||||
# SIGTERM'd mid-request.
|
||||
agent_worker = self._agent_worker if self._agent_running else None
|
||||
|
||||
if agent_worker is not None and not agent_worker.is_finished:
|
||||
|
||||
async def _graceful_exit() -> None:
|
||||
from textual.worker import WorkerCancelled, WorkerFailed
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(agent_worker.wait()),
|
||||
timeout=_GRACEFUL_EXIT_WAIT_SECONDS,
|
||||
)
|
||||
except (asyncio.CancelledError, WorkerCancelled):
|
||||
# Expected: exit() cancelled the worker above, so its
|
||||
# cancellation handler ran to completion. Nothing to flag.
|
||||
logger.debug(
|
||||
"Agent worker cancelled cleanly before app exit",
|
||||
exc_info=True,
|
||||
)
|
||||
except (TimeoutError, WorkerFailed):
|
||||
# The worker did not finish within the window, so the
|
||||
# in-flight run's server-side trace may be incomplete.
|
||||
# Surface above debug so the loss isn't silent.
|
||||
logger.warning(
|
||||
"Agent worker did not finish persisting before app "
|
||||
"exit; the in-flight run's trace may be incomplete",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Agent worker wait raised unexpectedly before app exit",
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
# This is the only call that stops the event loop, so it
|
||||
# must run on every path the try/except can take, including
|
||||
# an unexpected BaseException (e.g. SystemExit) propagating
|
||||
# out of the wait. Guard the teardown itself so a failure
|
||||
# here can't leave this fire-and-forget task with an
|
||||
# unretrieved exception; a non-Exception (SystemExit,
|
||||
# KeyboardInterrupt) still propagates. Explicit super()
|
||||
# form: the zero-arg super() can't resolve its implicit
|
||||
# __class__/self binding inside this nested coroutine, so
|
||||
# name the class and instance.
|
||||
try:
|
||||
super(DeepAgentsApp, self).exit(
|
||||
result=result,
|
||||
return_code=return_code,
|
||||
message=message,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"super().exit() raised during deferred teardown",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
self._graceful_exit_task = asyncio.ensure_future(_graceful_exit())
|
||||
else:
|
||||
super().exit(result=result, return_code=return_code, message=message)
|
||||
|
||||
def _get_subagent_panel(self) -> SubagentPanel | None:
|
||||
"""Return the subagent fan-out panel, or None if not yet mounted.
|
||||
|
||||
@@ -135,6 +135,82 @@ def _terminal_row_count(console: "Console", text: str) -> int:
|
||||
return max(1, len(console.render_lines(Text(text), console.options)))
|
||||
|
||||
|
||||
def _should_check_teardown_thread(
|
||||
thread_id: str | None,
|
||||
*,
|
||||
request_count: int,
|
||||
resume_thread: str | None,
|
||||
) -> bool:
|
||||
"""Return whether teardown should query for checkpointed thread content.
|
||||
|
||||
Any session that owns a thread may have persisted a checkpoint, so the only
|
||||
gate is whether a thread exists. `request_count` and `resume_thread` are
|
||||
accepted and ignored: an interrupted first turn can checkpoint before any
|
||||
usage metadata is recorded, so they are not a reliable proxy. They remain in
|
||||
the signature so callers need not change if the gate later grows selective.
|
||||
"""
|
||||
del request_count, resume_thread
|
||||
return bool(thread_id)
|
||||
|
||||
|
||||
def _render_teardown_thread_hints(
|
||||
console: "Console",
|
||||
thread_id: str,
|
||||
*,
|
||||
return_code: int,
|
||||
) -> None:
|
||||
"""Print the LangSmith link and resume hint for a checkpointed thread.
|
||||
|
||||
Both hints share a single `thread_exists` lookup to avoid spinning up a
|
||||
second event loop and aiosqlite connection during teardown. Every failure is
|
||||
logged at debug and swallowed: teardown convenience output must never crash
|
||||
the exit path.
|
||||
|
||||
Args:
|
||||
console: Console to print the hints to.
|
||||
thread_id: Thread whose checkpoints back the hints.
|
||||
return_code: Process exit code; the resume hint is shown only on a clean
|
||||
exit (`0`).
|
||||
"""
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
|
||||
from deepagents_code.config import build_langsmith_thread_url
|
||||
from deepagents_code.sessions import thread_exists
|
||||
|
||||
try:
|
||||
thread_has_checkpoints = asyncio.run(thread_exists(thread_id))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not check thread existence on teardown",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not thread_has_checkpoints:
|
||||
return
|
||||
|
||||
try:
|
||||
thread_url = build_langsmith_thread_url(thread_id)
|
||||
if thread_url:
|
||||
console.print()
|
||||
ls_hint = Text("View this thread in LangSmith: ", style="dim")
|
||||
ls_hint.append(thread_url, style=Style(dim=True, link=thread_url))
|
||||
console.print(ls_hint)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not display LangSmith thread URL on teardown",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if return_code == 0:
|
||||
console.print()
|
||||
console.print("[dim]Resume this thread with:[/dim]")
|
||||
hint = Text("dcode -r ", style="cyan")
|
||||
hint.append(str(thread_id), style="cyan")
|
||||
console.print(hint)
|
||||
|
||||
|
||||
def _confirm_update_after_restart(console: "Console", version: str) -> None:
|
||||
"""Rewrite the pre-restart `Launching...` line as a stable update status.
|
||||
|
||||
@@ -3265,16 +3341,9 @@ def cli_main() -> None:
|
||||
# Resolve recent-agent fallback only for actual session launches.
|
||||
assistant_id = _resolve_agent_arg(args)
|
||||
# Interactive mode - handle thread resume
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
|
||||
from deepagents_code.config import (
|
||||
build_langsmith_thread_url,
|
||||
)
|
||||
from deepagents_code.sessions import (
|
||||
generate_thread_id,
|
||||
thread_exists,
|
||||
)
|
||||
from deepagents_code.sessions import generate_thread_id
|
||||
|
||||
# Instead of resolving thread_id here with synchronous asyncio.run()
|
||||
# DB calls, pass the raw resume request to the TUI and let it
|
||||
@@ -3354,32 +3423,18 @@ def cli_main() -> None:
|
||||
console.print(Text(traceback.format_exc(), style="dim"))
|
||||
sys.exit(1)
|
||||
|
||||
# Show LangSmith thread link for threads with checkpointed
|
||||
# content (same table that backs the `/threads` listing).
|
||||
if thread_id:
|
||||
try:
|
||||
thread_url = build_langsmith_thread_url(thread_id)
|
||||
if thread_url and asyncio.run(thread_exists(thread_id)):
|
||||
console.print()
|
||||
ls_hint = Text("View this thread in LangSmith: ", style="dim")
|
||||
ls_hint.append(
|
||||
thread_url,
|
||||
style=Style(dim=True, link=thread_url),
|
||||
)
|
||||
console.print(ls_hint)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not display LangSmith thread URL on teardown",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Show resume hint on exit for threads with checkpointed content.
|
||||
if thread_id and return_code == 0 and asyncio.run(thread_exists(thread_id)):
|
||||
console.print()
|
||||
console.print("[dim]Resume this thread with:[/dim]")
|
||||
hint = Text("dcode -r ", style="cyan")
|
||||
hint.append(str(thread_id), style="cyan")
|
||||
console.print(hint)
|
||||
# Show LangSmith thread link and resume hint for threads with
|
||||
# checkpointed content. The `thread_id is not None` check narrows the
|
||||
# type to `str` for the helper; `_should_check_teardown_thread` gates
|
||||
# whether the teardown lookup runs at all.
|
||||
if thread_id is not None and _should_check_teardown_thread(
|
||||
thread_id,
|
||||
request_count=result.session_stats.request_count,
|
||||
resume_thread=args.resume_thread,
|
||||
):
|
||||
_render_teardown_thread_hints(
|
||||
console, thread_id, return_code=return_code
|
||||
)
|
||||
|
||||
# Warn about available update on exit
|
||||
try:
|
||||
|
||||
@@ -39,7 +39,7 @@ never a typed-in address — so it deliberately avoids binding the well-known
|
||||
_HEALTH_POLL_INTERVAL_LOCAL = 0.1
|
||||
_HEALTH_POLL_INTERVAL_REMOTE = 0.3
|
||||
_HEALTH_TIMEOUT = 60
|
||||
_SHUTDOWN_TIMEOUT = 5
|
||||
_SHUTDOWN_TIMEOUT = 3
|
||||
_LOG_TAIL_CHARS = 3000
|
||||
"""Max chars of subprocess log appended to the early-exit `RuntimeError`
|
||||
message. Enough to carry a Python traceback without flooding the TUI banner
|
||||
|
||||
@@ -7518,6 +7518,196 @@ class TestTerminalBackgroundSync:
|
||||
app_exit.assert_called_once()
|
||||
|
||||
|
||||
class TestExitGracefulWorkerHandoff:
|
||||
"""Verify `exit()` defers teardown for an in-flight agent worker."""
|
||||
|
||||
async def test_defers_when_agent_worker_unfinished(self) -> None:
|
||||
"""A running, unfinished worker arms a deferred graceful exit."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._agent_running = True
|
||||
worker = MagicMock()
|
||||
worker.is_finished = False
|
||||
worker.wait = AsyncMock()
|
||||
app._agent_worker = worker
|
||||
|
||||
with patch.object(App, "exit") as super_exit:
|
||||
app.exit()
|
||||
# Teardown is deferred, not synchronous.
|
||||
super_exit.assert_not_called()
|
||||
assert app._graceful_exit_task is not None
|
||||
# The deferred task waits on the worker, then tears down.
|
||||
await app._graceful_exit_task
|
||||
super_exit.assert_called_once()
|
||||
worker.wait.assert_awaited_once()
|
||||
|
||||
async def test_timeout_does_not_cancel_worker_wait(self) -> None:
|
||||
"""A timed-out graceful exit must not abort worker cleanup."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
wait_started = asyncio.Event()
|
||||
wait_done = asyncio.Event()
|
||||
wait_future: asyncio.Future[None] = asyncio.Future()
|
||||
wait_cancelled = False
|
||||
|
||||
async def wait_worker() -> None:
|
||||
nonlocal wait_cancelled
|
||||
wait_started.set()
|
||||
try:
|
||||
await wait_future
|
||||
except asyncio.CancelledError:
|
||||
wait_cancelled = True
|
||||
raise
|
||||
finally:
|
||||
wait_done.set()
|
||||
|
||||
app._agent_running = True
|
||||
worker = MagicMock()
|
||||
worker.is_finished = False
|
||||
worker.wait = AsyncMock(side_effect=wait_worker)
|
||||
app._agent_worker = worker
|
||||
|
||||
with (
|
||||
patch("deepagents_code.app._GRACEFUL_EXIT_WAIT_SECONDS", 0.01),
|
||||
patch.object(App, "exit") as super_exit,
|
||||
):
|
||||
app.exit()
|
||||
assert app._graceful_exit_task is not None
|
||||
await wait_started.wait()
|
||||
await app._graceful_exit_task
|
||||
|
||||
super_exit.assert_called_once()
|
||||
|
||||
assert not wait_cancelled
|
||||
assert not wait_future.cancelled()
|
||||
|
||||
wait_future.set_result(None)
|
||||
await asyncio.wait_for(wait_done.wait(), timeout=1.0)
|
||||
worker.wait.assert_awaited_once()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_key", "expected_level", "expected_substring"),
|
||||
[
|
||||
("worker_failed", logging.WARNING, "did not finish persisting"),
|
||||
("unexpected", logging.WARNING, "raised unexpectedly"),
|
||||
("worker_cancelled", logging.DEBUG, "cancelled cleanly"),
|
||||
],
|
||||
)
|
||||
async def test_graceful_exit_always_tears_down(
|
||||
self,
|
||||
error_key: str,
|
||||
expected_level: int,
|
||||
expected_substring: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Every exception path through `_graceful_exit` still tears down.
|
||||
|
||||
The `finally` block runs `super().exit()` on all branches (the only
|
||||
call that stops the event loop), and each branch logs at the
|
||||
documented level.
|
||||
"""
|
||||
from textual.worker import WorkerCancelled, WorkerFailed
|
||||
|
||||
errors: dict[str, BaseException] = {
|
||||
"worker_failed": WorkerFailed(ValueError("boom")),
|
||||
"unexpected": RuntimeError("boom"),
|
||||
"worker_cancelled": WorkerCancelled("cancelled"),
|
||||
}
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._agent_running = True
|
||||
worker = MagicMock()
|
||||
worker.is_finished = False
|
||||
worker.wait = AsyncMock(side_effect=errors[error_key])
|
||||
app._agent_worker = worker
|
||||
|
||||
with (
|
||||
caplog.at_level(logging.DEBUG, logger="deepagents_code.app"),
|
||||
patch.object(App, "exit") as super_exit,
|
||||
):
|
||||
app.exit()
|
||||
assert app._graceful_exit_task is not None
|
||||
await app._graceful_exit_task
|
||||
# The finally block tears down on every exception path.
|
||||
super_exit.assert_called_once()
|
||||
|
||||
matching = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == expected_level and expected_substring in record.message
|
||||
]
|
||||
assert matching, (
|
||||
f"expected a {logging.getLevelName(expected_level)} log containing "
|
||||
f"{expected_substring!r}"
|
||||
)
|
||||
|
||||
async def test_synchronous_when_worker_finished(self) -> None:
|
||||
"""A finished worker tears down synchronously with no deferred task."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._agent_running = True
|
||||
worker = MagicMock()
|
||||
worker.is_finished = True
|
||||
worker.wait = AsyncMock()
|
||||
app._agent_worker = worker
|
||||
|
||||
with patch.object(App, "exit") as super_exit:
|
||||
app.exit()
|
||||
super_exit.assert_called_once()
|
||||
assert app._graceful_exit_task is None
|
||||
worker.wait.assert_not_awaited()
|
||||
|
||||
async def test_synchronous_when_agent_not_running(self) -> None:
|
||||
"""An idle session tears down synchronously even with a worker set."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._agent_running = False
|
||||
worker = MagicMock()
|
||||
worker.is_finished = False
|
||||
worker.wait = AsyncMock()
|
||||
app._agent_worker = worker
|
||||
|
||||
with patch.object(App, "exit") as super_exit:
|
||||
app.exit()
|
||||
super_exit.assert_called_once()
|
||||
assert app._graceful_exit_task is None
|
||||
worker.wait.assert_not_awaited()
|
||||
|
||||
async def test_second_exit_force_quits_pending_graceful_exit(self) -> None:
|
||||
"""A second exit() during a pending graceful exit force-quits.
|
||||
|
||||
Mashing Ctrl+D/Ctrl+C to bail out must not arm a second bounded wait;
|
||||
it should tear down immediately and leave the first task untouched.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._agent_running = True
|
||||
worker = MagicMock()
|
||||
worker.is_finished = False
|
||||
worker.wait = AsyncMock()
|
||||
app._agent_worker = worker
|
||||
|
||||
with patch.object(App, "exit") as super_exit:
|
||||
app.exit()
|
||||
pending = app._graceful_exit_task
|
||||
assert pending is not None
|
||||
super_exit.assert_not_called()
|
||||
|
||||
# Second press before the loop runs the deferred task.
|
||||
app.exit()
|
||||
super_exit.assert_called_once()
|
||||
# The pending task is not re-armed or replaced.
|
||||
assert app._graceful_exit_task is pending
|
||||
pending.cancel()
|
||||
|
||||
|
||||
class TestSlashCommandBypass:
|
||||
"""Test that certain slash commands bypass the queue gate."""
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@ from deepagents_code.config import build_langsmith_thread_url, reset_langsmith_u
|
||||
from deepagents_code.main import (
|
||||
_auto_install_ripgrep_cli,
|
||||
_is_managed_ripgrep_path,
|
||||
_render_teardown_thread_hints,
|
||||
_restart_current_process,
|
||||
_ripgrep_install_hint,
|
||||
_run_startup_auto_update,
|
||||
_should_check_teardown_thread,
|
||||
_terminal_row_count,
|
||||
build_missing_tool_notification,
|
||||
check_optional_tools,
|
||||
@@ -1082,6 +1084,129 @@ class TestResumeHintLogic:
|
||||
assert not show, "No hint when thread_exists returns False"
|
||||
|
||||
|
||||
class TestTeardownThreadCheckpointLookup:
|
||||
"""Test teardown checkpoint lookup guard behavior."""
|
||||
|
||||
def test_checks_fresh_thread_without_requests(self) -> None:
|
||||
"""Fresh interrupted sessions can checkpoint before usage is recorded."""
|
||||
should_check = _should_check_teardown_thread(
|
||||
"test123",
|
||||
request_count=0,
|
||||
resume_thread=None,
|
||||
)
|
||||
|
||||
assert should_check
|
||||
|
||||
def test_checks_fresh_thread_after_requests(self) -> None:
|
||||
"""Sessions that made requests may have checkpointed content."""
|
||||
should_check = _should_check_teardown_thread(
|
||||
"test123",
|
||||
request_count=1,
|
||||
resume_thread=None,
|
||||
)
|
||||
|
||||
assert should_check
|
||||
|
||||
def test_checks_resumed_thread_without_new_requests(self) -> None:
|
||||
"""Resumed sessions can already have checkpoints before new requests."""
|
||||
should_check = _should_check_teardown_thread(
|
||||
"test123",
|
||||
request_count=0,
|
||||
resume_thread="test123",
|
||||
)
|
||||
|
||||
assert should_check
|
||||
|
||||
def test_skips_when_no_thread_id(self) -> None:
|
||||
"""No final thread means there is nothing to look up."""
|
||||
should_check = _should_check_teardown_thread(
|
||||
None,
|
||||
request_count=1,
|
||||
resume_thread="test123",
|
||||
)
|
||||
|
||||
assert not should_check
|
||||
|
||||
|
||||
class TestRenderTeardownThreadHints:
|
||||
"""Test the teardown hint renderer shares one `thread_exists` lookup."""
|
||||
|
||||
def _render(
|
||||
self,
|
||||
*,
|
||||
thread_exists_mock: AsyncMock,
|
||||
thread_url: str | None,
|
||||
return_code: int = 0,
|
||||
) -> str:
|
||||
"""Render the hints with patched dependencies, returning the output."""
|
||||
buffer = StringIO()
|
||||
console = Console(file=buffer, width=200)
|
||||
with (
|
||||
patch("deepagents_code.sessions.thread_exists", thread_exists_mock),
|
||||
patch(
|
||||
"deepagents_code.config.build_langsmith_thread_url",
|
||||
return_value=thread_url,
|
||||
),
|
||||
):
|
||||
_render_teardown_thread_hints(console, "test123", return_code=return_code)
|
||||
return buffer.getvalue()
|
||||
|
||||
def test_queries_thread_exists_at_most_once(self) -> None:
|
||||
"""Both hints must share a single checkpoint lookup, never two.
|
||||
|
||||
Guards against a regression that reintroduces a second
|
||||
`asyncio.run(thread_exists(...))` (a fresh event loop + aiosqlite
|
||||
connection) during teardown.
|
||||
"""
|
||||
thread_exists_mock = AsyncMock(return_value=True)
|
||||
|
||||
output = self._render(thread_exists_mock=thread_exists_mock, thread_url=None)
|
||||
|
||||
thread_exists_mock.assert_awaited_once()
|
||||
assert "Resume this thread with:" in output
|
||||
assert "dcode -r test123" in output
|
||||
|
||||
def test_prints_langsmith_link_when_available(self) -> None:
|
||||
"""A configured LangSmith URL is shown alongside the resume hint."""
|
||||
thread_exists_mock = AsyncMock(return_value=True)
|
||||
url = "https://smith.langchain.com/o/org/projects/p/proj/t/test123"
|
||||
|
||||
output = self._render(thread_exists_mock=thread_exists_mock, thread_url=url)
|
||||
|
||||
assert "View this thread in LangSmith:" in output
|
||||
assert "Resume this thread with:" in output
|
||||
thread_exists_mock.assert_awaited_once()
|
||||
|
||||
def test_no_hints_without_checkpoints(self) -> None:
|
||||
"""No checkpoint means no link and no resume hint."""
|
||||
thread_exists_mock = AsyncMock(return_value=False)
|
||||
|
||||
output = self._render(thread_exists_mock=thread_exists_mock, thread_url=None)
|
||||
|
||||
assert output == ""
|
||||
thread_exists_mock.assert_awaited_once()
|
||||
|
||||
def test_lookup_failure_is_swallowed(self) -> None:
|
||||
"""A failed checkpoint lookup must not crash teardown or print hints."""
|
||||
thread_exists_mock = AsyncMock(side_effect=RuntimeError("db locked"))
|
||||
|
||||
output = self._render(thread_exists_mock=thread_exists_mock, thread_url=None)
|
||||
|
||||
assert output == ""
|
||||
thread_exists_mock.assert_awaited_once()
|
||||
|
||||
def test_resume_hint_omitted_on_error_exit(self) -> None:
|
||||
"""The resume hint is only shown on a clean exit (return_code 0)."""
|
||||
thread_exists_mock = AsyncMock(return_value=True)
|
||||
|
||||
output = self._render(
|
||||
thread_exists_mock=thread_exists_mock, thread_url=None, return_code=1
|
||||
)
|
||||
|
||||
assert "Resume this thread with:" not in output
|
||||
thread_exists_mock.assert_awaited_once()
|
||||
|
||||
|
||||
class TestLangSmithTeardownUrl:
|
||||
"""Test LangSmith thread URL display logic on teardown."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user