mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +01:00
todo_hooks: session-start, canonical token path
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6f0070b353
commit
1fb04ce59d
@ -20,7 +20,7 @@ Run as `scripts/todo_hooks.sh <command>` or `PYTHONPATH=<utils> python3 -m todo_
|
|||||||
|
|
||||||
## Server
|
## Server
|
||||||
|
|
||||||
`TODO_MCP_URL` (default `http://127.0.0.1:8735/mcp`), token from `TODO_MCP_TOKEN` else `~/.config/todo-mcp/client-token` (0600). Handshake: `initialize`, `notifications/initialized`, then `tools/call`, all with the `Mcp-Session-Id` the first reply returns.
|
`TODO_MCP_URL` (default `http://127.0.0.1:8735/mcp`), token from `TODO_MCP_TOKEN` else `~/.config/todo-mcp-client/token` (0600). Handshake: `initialize`, `notifications/initialized`, then `tools/call`, all with the `Mcp-Session-Id` the first reply returns.
|
||||||
|
|
||||||
## Wiring a repo
|
## Wiring a repo
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from todo_hooks import closer, import_pr, sweep, trust_cmd
|
from todo_hooks import closer, import_pr, session_start, sweep, trust_cmd
|
||||||
from todo_hooks.context import Context
|
from todo_hooks.context import Context
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -14,7 +14,9 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from todo_hooks.client import TaskClient
|
from todo_hooks.client import TaskClient
|
||||||
|
|
||||||
USAGE = "usage: python3 -m todo_hooks {closer|sweep|trust|import-pr} [args]"
|
USAGE = (
|
||||||
|
"usage: python3 -m todo_hooks {closer|sweep|trust|import-pr|session-start} [args]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main(
|
def main(
|
||||||
@ -27,13 +29,22 @@ def main(
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""Dispatch one subcommand; returns the process exit code."""
|
"""Dispatch one subcommand; returns the process exit code."""
|
||||||
args = list(sys.argv[1:] if argv is None else argv)
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
if not args or args[0] not in {"closer", "sweep", "trust", "import-pr"}:
|
if not args or args[0] not in {
|
||||||
|
"closer",
|
||||||
|
"sweep",
|
||||||
|
"trust",
|
||||||
|
"import-pr",
|
||||||
|
"session-start",
|
||||||
|
}:
|
||||||
print(USAGE)
|
print(USAGE)
|
||||||
return 2
|
return 2
|
||||||
ctx = Context.build(env, cwd, make_client)
|
budget = session_start.BUDGET_SECONDS if args[0] == "session-start" else None
|
||||||
|
ctx = Context.build(env, cwd, make_client, budget)
|
||||||
command, rest = args[0], args[1:]
|
command, rest = args[0], args[1:]
|
||||||
if command == "closer":
|
if command == "closer":
|
||||||
return closer.run(ctx)
|
return closer.run(ctx)
|
||||||
|
if command == "session-start":
|
||||||
|
return session_start.run(ctx, sys.stdin.read() if stdin is None else stdin)
|
||||||
if command == "sweep":
|
if command == "sweep":
|
||||||
return sweep.main(ctx, rest)
|
return sweep.main(ctx, rest)
|
||||||
if command == "trust":
|
if command == "trust":
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import contextlib
|
import contextlib
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING, Any, Protocol, Self
|
from typing import TYPE_CHECKING, Any, Protocol, Self
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@ -84,24 +85,35 @@ def _parse_reply(content_type: str, body: str) -> dict[str, Any]:
|
|||||||
class McpClient:
|
class McpClient:
|
||||||
"""One lazily initialised MCP session; ``close`` it (or use ``with``)."""
|
"""One lazily initialised MCP session; ``close`` it (or use ``with``)."""
|
||||||
|
|
||||||
def __init__(self, url: str, token: str, timeout: float = DEFAULT_TIMEOUT) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
token: str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
budget: float | None = None,
|
||||||
|
) -> None:
|
||||||
if not url.startswith(("http://", "https://")):
|
if not url.startswith(("http://", "https://")):
|
||||||
msg = f"unsupported MCP url: {url}"
|
msg = f"unsupported MCP url: {url}"
|
||||||
raise McpError(msg)
|
raise McpError(msg)
|
||||||
self.url = url
|
self.url = url
|
||||||
self._token = token
|
self._token = token
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
|
self._deadline = None if budget is None else time.monotonic() + budget
|
||||||
self._session: str | None = None
|
self._session: str | None = None
|
||||||
self._ready = False
|
self._ready = False
|
||||||
self._next_id = 0
|
self._next_id = 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
cls, env: Mapping[str, str], token_file: Path, timeout: float = DEFAULT_TIMEOUT
|
cls,
|
||||||
|
env: Mapping[str, str],
|
||||||
|
token_file: Path,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
budget: float | None = None,
|
||||||
) -> McpClient:
|
) -> McpClient:
|
||||||
"""Build from ``TODO_MCP_URL`` and the token (env or file)."""
|
"""Build from ``TODO_MCP_URL`` and the token (env or file)."""
|
||||||
url = env.get("TODO_MCP_URL") or DEFAULT_URL
|
url = env.get("TODO_MCP_URL") or DEFAULT_URL
|
||||||
return cls(url, load_token(env, token_file), timeout)
|
return cls(url, load_token(env, token_file), timeout, budget)
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
return self
|
return self
|
||||||
@ -119,12 +131,18 @@ class McpClient:
|
|||||||
}
|
}
|
||||||
if self._session:
|
if self._session:
|
||||||
headers["Mcp-Session-Id"] = self._session
|
headers["Mcp-Session-Id"] = self._session
|
||||||
|
timeout = self._timeout
|
||||||
|
if self._deadline is not None:
|
||||||
|
timeout = min(timeout, self._deadline - time.monotonic())
|
||||||
|
if timeout <= 0:
|
||||||
|
msg = "time budget exhausted"
|
||||||
|
raise McpUnavailable(msg)
|
||||||
data = json.dumps(payload).encode() if payload is not None else None
|
data = json.dumps(payload).encode() if payload is not None else None
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
self.url, data=data, headers=headers, method=method
|
self.url, data=data, headers=headers, method=method
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=self._timeout) as reply:
|
with urllib.request.urlopen(request, timeout=timeout) as reply:
|
||||||
body = reply.read().decode("utf-8", "replace")
|
body = reply.read().decode("utf-8", "replace")
|
||||||
return reply.headers.get("Content-Type", ""), body, reply.headers
|
return reply.headers.get("Content-Type", ""), body, reply.headers
|
||||||
except urllib.error.HTTPError as err:
|
except urllib.error.HTTPError as err:
|
||||||
|
|||||||
@ -34,13 +34,14 @@ class Context:
|
|||||||
env: Mapping[str, str] | None = None,
|
env: Mapping[str, str] | None = None,
|
||||||
cwd: Path | None = None,
|
cwd: Path | None = None,
|
||||||
make_client: Callable[[], TaskClient] | None = None,
|
make_client: Callable[[], TaskClient] | None = None,
|
||||||
|
budget: float | None = None,
|
||||||
) -> Context:
|
) -> Context:
|
||||||
"""Real defaults: ``os.environ``, the cwd, ``~`` paths, a live client."""
|
"""Real defaults: ``os.environ``, the cwd, ``~`` paths, a live client."""
|
||||||
use_env = dict(os.environ) if env is None else dict(env)
|
use_env = dict(os.environ) if env is None else dict(env)
|
||||||
paths = Paths.default(use_env)
|
paths = Paths.default(use_env)
|
||||||
|
|
||||||
def live() -> TaskClient:
|
def live() -> TaskClient:
|
||||||
return McpClient.from_env(use_env, paths.token_file)
|
return McpClient.from_env(use_env, paths.token_file, budget=budget)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
env=use_env,
|
env=use_env,
|
||||||
|
|||||||
@ -28,7 +28,7 @@ class Paths:
|
|||||||
return cls(
|
return cls(
|
||||||
token_file=Path(
|
token_file=Path(
|
||||||
env.get("TODO_MCP_TOKEN_FILE")
|
env.get("TODO_MCP_TOKEN_FILE")
|
||||||
or base / ".config" / "todo-mcp" / "client-token"
|
or base / ".config" / "todo-mcp-client" / "token"
|
||||||
),
|
),
|
||||||
trust_file=Path(
|
trust_file=Path(
|
||||||
env.get("TODO_HOOKS_TRUST_FILE")
|
env.get("TODO_HOOKS_TRUST_FILE")
|
||||||
|
|||||||
57
todo_hooks/session_start.py
Normal file
57
todo_hooks/session_start.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""SessionStart hook: print the current repo's open tasks, at most ten lines."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from todo_hooks.gitrepo import expected_repo, origin_url, toplevel
|
||||||
|
from todo_hooks.notes import parse_list
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from todo_hooks.context import Context
|
||||||
|
|
||||||
|
BUDGET_SECONDS = 0.8
|
||||||
|
MAX_LINES = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _cwd_from(ctx: Context, stdin: str) -> Path:
|
||||||
|
try:
|
||||||
|
data = json.loads(stdin)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return ctx.cwd
|
||||||
|
cwd = data.get("cwd") if isinstance(data, dict) else None
|
||||||
|
return Path(cwd) if cwd else ctx.cwd
|
||||||
|
|
||||||
|
|
||||||
|
def summary(repo: str, listing: str) -> list[str]:
|
||||||
|
"""Header plus task lines, truncated to MAX_LINES with a '+K more' tail."""
|
||||||
|
rows = parse_list(listing)
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
out = [f"Open todo tasks for {repo} ({len(rows)}):"]
|
||||||
|
shown = rows if len(rows) < MAX_LINES else rows[: MAX_LINES - 2]
|
||||||
|
out += [f"- {row.id8} {row.title}" for row in shown]
|
||||||
|
if len(shown) < len(rows):
|
||||||
|
out.append(f'… +{len(rows) - len(shown)} more: list_tasks(repo="{repo}")')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def run(ctx: Context, stdin: str) -> int:
|
||||||
|
"""Always 0 and silent on any problem: a session must never wait on this."""
|
||||||
|
try:
|
||||||
|
root = toplevel(_cwd_from(ctx, stdin))
|
||||||
|
if root is None or origin_url(root) is None:
|
||||||
|
return 0
|
||||||
|
repo = expected_repo(root)
|
||||||
|
client = ctx.make_client()
|
||||||
|
try:
|
||||||
|
listing = client.call("list_tasks", {"repo": repo, "status": "open"})
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
for row in summary(repo, listing):
|
||||||
|
ctx.out(row)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
@ -196,3 +196,21 @@ def test_non_object_json_is_unparseable() -> None:
|
|||||||
def test_non_http_url_is_refused() -> None:
|
def test_non_http_url_is_refused() -> None:
|
||||||
with pytest.raises(McpError, match="unsupported"):
|
with pytest.raises(McpError, match="unsupported"):
|
||||||
McpClient("file:///etc/passwd", "t")
|
McpClient("file:///etc/passwd", "t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_exhausted_budget_fails_before_any_request(mcp: FakeMcp) -> None:
|
||||||
|
with pytest.raises(McpUnavailable, match="budget"):
|
||||||
|
McpClient(mcp.url, TOKEN, budget=0).call("list_tasks")
|
||||||
|
assert mcp.requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_caps_each_request(mcp: FakeMcp) -> None:
|
||||||
|
mcp.delay = 1.0
|
||||||
|
with pytest.raises(McpUnavailable):
|
||||||
|
McpClient(mcp.url, TOKEN, timeout=5, budget=0.3).call("list_tasks")
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_env_passes_budget(tmp_path: Path) -> None:
|
||||||
|
file = tmp_path / "t"
|
||||||
|
file.write_text("tok")
|
||||||
|
assert McpClient.from_env({}, file, budget=0).call is not None
|
||||||
|
|||||||
@ -102,7 +102,7 @@ def test_paths_default_and_env_overrides(tmp_path: Path) -> None:
|
|||||||
capped.parent.mkdir(parents=True)
|
capped.parent.mkdir(parents=True)
|
||||||
capped.write_text("#!/bin/sh\n")
|
capped.write_text("#!/bin/sh\n")
|
||||||
base = Paths.default({}, tmp_path)
|
base = Paths.default({}, tmp_path)
|
||||||
assert base.token_file == tmp_path / ".config/todo-mcp/client-token"
|
assert base.token_file == tmp_path / ".config/todo-mcp-client/token"
|
||||||
assert base.trust_file == tmp_path / ".config/todo-hooks/trusted-checks"
|
assert base.trust_file == tmp_path / ".config/todo-hooks/trusted-checks"
|
||||||
assert base.report_file == tmp_path / ".local/state/todo-drift/report.txt"
|
assert base.report_file == tmp_path / ".local/state/todo-drift/report.txt"
|
||||||
assert base.src_root == tmp_path / "src"
|
assert base.src_root == tmp_path / "src"
|
||||||
|
|||||||
140
todo_hooks/tests/test_session_start.py
Normal file
140
todo_hooks/tests/test_session_start.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"""Tests for the SessionStart task summary."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from todo_hooks import __main__ as cli
|
||||||
|
from todo_hooks.tests._fakes import TOKEN, FakeMcp, line
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ORIGIN = "git@github.com:kuhyx/demo.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(count: int) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
line(f"{i:08x}-x", "kuhyx/demo", f"# Task {i}") for i in range(count)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _env(mcp: FakeMcp) -> dict[str, str]:
|
||||||
|
return {"TODO_MCP_URL": mcp.url, "TODO_MCP_TOKEN": TOKEN}
|
||||||
|
|
||||||
|
|
||||||
|
def _stdin(cwd: Path) -> str:
|
||||||
|
return json.dumps({"cwd": str(cwd), "hook_event_name": "SessionStart"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_prints_header_and_tasks(
|
||||||
|
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
root = make_repo("demo", origin=ORIGIN)
|
||||||
|
seen: list[dict[str, object]] = []
|
||||||
|
mcp.tools["list_tasks"] = lambda a: seen.append(a) or _rows(2)
|
||||||
|
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0
|
||||||
|
assert capsys.readouterr().out.splitlines() == [
|
||||||
|
"Open todo tasks for kuhyx/demo (2):",
|
||||||
|
"- 00000000 Task 0",
|
||||||
|
"- 00000001 Task 1",
|
||||||
|
]
|
||||||
|
assert seen == [{"repo": "kuhyx/demo", "status": "open"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nine_tasks_fit_ten_lines(
|
||||||
|
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
root = make_repo("demo", origin=ORIGIN)
|
||||||
|
mcp.tools["list_tasks"] = lambda _a: _rows(9)
|
||||||
|
cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root))
|
||||||
|
assert len(capsys.readouterr().out.splitlines()) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_more_than_nine_is_truncated(
|
||||||
|
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
root = make_repo("demo", origin=ORIGIN)
|
||||||
|
mcp.tools["list_tasks"] = lambda _a: _rows(15)
|
||||||
|
cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root))
|
||||||
|
lines = capsys.readouterr().out.splitlines()
|
||||||
|
assert len(lines) == 10
|
||||||
|
assert lines[0] == "Open todo tasks for kuhyx/demo (15):"
|
||||||
|
assert lines[-1] == '… +7 more: list_tasks(repo="kuhyx/demo")'
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_tasks_prints_nothing(
|
||||||
|
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
root = make_repo("demo", origin=ORIGIN)
|
||||||
|
mcp.tools["list_tasks"] = lambda _a: "no tasks match"
|
||||||
|
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_cwd_fallback_when_stdin_is_not_json(
|
||||||
|
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
root = make_repo("demo", origin=ORIGIN)
|
||||||
|
mcp.tools["list_tasks"] = lambda _a: _rows(1)
|
||||||
|
cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="not json")
|
||||||
|
assert "kuhyx/demo" in capsys.readouterr().out
|
||||||
|
cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="[1]")
|
||||||
|
assert "kuhyx/demo" in capsys.readouterr().out
|
||||||
|
cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="{}")
|
||||||
|
assert "kuhyx/demo" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_without_git_or_origin(
|
||||||
|
mcp: FakeMcp,
|
||||||
|
make_repo: Callable[..., Path],
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
mcp.tools["list_tasks"] = lambda _a: _rows(1)
|
||||||
|
plain = tmp_path / "plain"
|
||||||
|
plain.mkdir()
|
||||||
|
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(plain)) == 0
|
||||||
|
local = make_repo("local")
|
||||||
|
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(local)) == 0
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
assert mcp.requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_when_server_down_or_token_bad(
|
||||||
|
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
root = make_repo("demo", origin=ORIGIN)
|
||||||
|
bad = {**_env(mcp), "TODO_MCP_TOKEN": "wrong"}
|
||||||
|
assert cli.main(["session-start"], env=bad, stdin=_stdin(root)) == 0
|
||||||
|
dead = {**_env(mcp), "TODO_MCP_URL": "http://127.0.0.1:1/mcp"}
|
||||||
|
assert cli.main(["session-start"], env=dead, stdin=_stdin(root)) == 0
|
||||||
|
no_token = {"TODO_MCP_URL": mcp.url, "TODO_MCP_TOKEN_FILE": "/nonexistent"}
|
||||||
|
assert cli.main(["session-start"], env=no_token, stdin=_stdin(root)) == 0
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_on_unexpected_error(
|
||||||
|
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
root = make_repo("demo", origin=ORIGIN)
|
||||||
|
mcp.mode = "garbage"
|
||||||
|
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_server_stays_within_budget(
|
||||||
|
mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
root = make_repo("demo", origin=ORIGIN)
|
||||||
|
mcp.delay = 3.0
|
||||||
|
mcp.tools["list_tasks"] = lambda _a: _rows(1)
|
||||||
|
start = time.monotonic()
|
||||||
|
assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0
|
||||||
|
assert time.monotonic() - start < 1.6
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
Loading…
Reference in New Issue
Block a user