@opengsd/gsd-pi 1.4.0-dev.5fc22c6f → 1.4.0-dev.c18009cd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/claude-code-cli/stream-adapter.js +58 -4
- package/dist/resources/extensions/gsd/artifact-verification.js +17 -5
- package/dist/resources/extensions/gsd/auto/closeout.js +45 -42
- package/dist/resources/extensions/gsd/auto/finalize.js +9 -0
- package/dist/resources/extensions/gsd/auto/orchestrator.js +1 -1
- package/dist/resources/extensions/gsd/auto/run-unit.js +5 -2
- package/dist/resources/extensions/gsd/auto/schedule-wakeup.js +3 -0
- package/dist/resources/extensions/gsd/auto.js +10 -3
- package/dist/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.js +58 -9
- package/dist/resources/extensions/gsd/doctor-engine-checks.js +174 -16
- package/dist/resources/extensions/gsd/gsd-db.js +52 -0
- package/dist/resources/extensions/gsd/migrate-external.js +13 -4
- package/dist/resources/extensions/gsd/milestone-merge-transaction.js +3 -3
- package/dist/resources/extensions/gsd/repo-identity.js +16 -0
- package/dist/resources/extensions/gsd/root-write-leak-guard.js +4 -2
- package/dist/resources/extensions/gsd/unit-registry.js +27 -2
- package/dist/resources/extensions/gsd/worktree-lifecycle.js +67 -5
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/integrations/hermes/CONTEXT.md +47 -0
- package/integrations/hermes/README.md +21 -0
- package/integrations/hermes/docs/issue-1162-grilling.md +197 -0
- package/integrations/hermes/docs/setup.md +4 -0
- package/integrations/hermes/open_gsd_hermes/commands.py +141 -0
- package/integrations/hermes/open_gsd_hermes/config.py +2 -0
- package/integrations/hermes/open_gsd_hermes/gsd_client.py +594 -31
- package/integrations/hermes/open_gsd_hermes/notifications.py +10 -1
- package/integrations/hermes/plugin.yaml +4 -0
- package/integrations/hermes/tests/test_config.py +6 -0
- package/integrations/hermes/tests/test_gsd_client_cache.py +3 -0
- package/integrations/hermes/tests/test_gsd_client_execute.py +35 -0
- package/integrations/hermes/tests/test_gsd_client_process_cwd.py +22 -0
- package/integrations/hermes/tests/test_gsd_client_timeout.py +98 -0
- package/integrations/hermes/tests/test_milestone_client.py +447 -0
- package/integrations/hermes/tests/test_new_milestone_command.py +313 -0
- package/integrations/hermes/tests/test_supervisor_fsm.py +40 -0
- package/package.json +1 -1
- package/packages/cloud-mcp-gateway/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/dist/session-manager.js +1 -1
- package/packages/daemon/dist/session-manager.js.map +1 -1
- package/packages/daemon/package.json +4 -4
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.d.ts.map +1 -1
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.js +4 -1
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.js.map +1 -1
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/mcp-server/dist/server.d.ts +18 -5
- package/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +46 -5
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/dist/session-manager.js +1 -1
- package/packages/mcp-server/dist/session-manager.js.map +1 -1
- package/packages/mcp-server/package.json +4 -4
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts +1 -0
- package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager-types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.js +3 -0
- package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
- package/packages/pi-coding-agent/package.json +7 -7
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/claude-code-cli/stream-adapter.ts +64 -3
- package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +212 -39
- package/src/resources/extensions/gsd/artifact-verification.ts +16 -4
- package/src/resources/extensions/gsd/auto/closeout.ts +54 -56
- package/src/resources/extensions/gsd/auto/finalize.ts +8 -0
- package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
- package/src/resources/extensions/gsd/auto/orchestrator.ts +1 -1
- package/src/resources/extensions/gsd/auto/run-unit.ts +5 -2
- package/src/resources/extensions/gsd/auto/schedule-wakeup.ts +8 -0
- package/src/resources/extensions/gsd/auto.ts +9 -3
- package/src/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.ts +80 -12
- package/src/resources/extensions/gsd/doctor-engine-checks.ts +230 -21
- package/src/resources/extensions/gsd/gsd-db.ts +72 -0
- package/src/resources/extensions/gsd/migrate-external.ts +14 -4
- package/src/resources/extensions/gsd/milestone-merge-transaction.ts +4 -4
- package/src/resources/extensions/gsd/repo-identity.ts +17 -0
- package/src/resources/extensions/gsd/root-write-leak-guard.ts +3 -2
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +131 -5
- package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +43 -1
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
- package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +335 -0
- package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +116 -0
- package/src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts +152 -22
- package/src/resources/extensions/gsd/tests/milestone-merge-transaction.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +50 -6
- package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +31 -9
- package/src/resources/extensions/gsd/tests/schedule-wakeup-tool.test.ts +167 -0
- package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/unit-registry.test.ts +56 -0
- package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +2 -2
- package/src/resources/extensions/gsd/unit-registry.ts +34 -2
- package/src/resources/extensions/gsd/worktree-lifecycle.ts +135 -11
- /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_ssgManifest.js +0 -0
|
@@ -52,12 +52,21 @@ class NotificationService:
|
|
|
52
52
|
|
|
53
53
|
def notify_blocker(self, status: SessionStatus) -> None:
|
|
54
54
|
blocker = status.pending_blocker or {}
|
|
55
|
-
q =
|
|
55
|
+
q = (
|
|
56
|
+
blocker.get("question")
|
|
57
|
+
or blocker.get("prompt")
|
|
58
|
+
or blocker.get("title")
|
|
59
|
+
or blocker.get("message")
|
|
60
|
+
or "Action required"
|
|
61
|
+
)
|
|
56
62
|
self.send(f"🚧 GSD blocker: {q}\nReply with `/gsd reply <your answer>`", kind="blocker")
|
|
57
63
|
|
|
58
64
|
def notify_transition(self, message: str) -> None:
|
|
59
65
|
self.send(f"📋 GSD: {message}", kind="transition")
|
|
60
66
|
|
|
67
|
+
def notify_milestone_complete(self, message: str) -> None:
|
|
68
|
+
self.send(message, kind="complete")
|
|
69
|
+
|
|
61
70
|
def notify_terminal(self, status: str, error: str | None = None) -> None:
|
|
62
71
|
normalized_status = status.lower()
|
|
63
72
|
if normalized_status in ("complete", "completed", "done"):
|
|
@@ -32,6 +32,10 @@ config_schema:
|
|
|
32
32
|
cache_ttl_seconds:
|
|
33
33
|
type: integer
|
|
34
34
|
default: 45
|
|
35
|
+
mcp_read_timeout_seconds:
|
|
36
|
+
type: number
|
|
37
|
+
default: 60
|
|
38
|
+
description: Seconds to wait for one gsd-mcp-server response before resetting the MCP sidecar
|
|
35
39
|
notification_level:
|
|
36
40
|
type: string
|
|
37
41
|
enum: [quiet, normal, verbose]
|
|
@@ -21,6 +21,12 @@ def test_dict_fallback_gsd_version_range_matches_plugin_release_train() -> None:
|
|
|
21
21
|
assert config.gsd_version_max == "3.0"
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def test_mcp_read_timeout_can_be_configured() -> None:
|
|
25
|
+
config = GsdConfig.from_dict({"gsd": {"mcp_read_timeout_seconds": 12.5}})
|
|
26
|
+
|
|
27
|
+
assert config.mcp_read_timeout_seconds == 12.5
|
|
28
|
+
|
|
29
|
+
|
|
24
30
|
def test_config_path_respects_hermes_home(tmp_path: Path, monkeypatch) -> None:
|
|
25
31
|
monkeypatch.delenv("HERMES_GSD_CONFIG", raising=False)
|
|
26
32
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
|
|
@@ -70,6 +70,7 @@ def test_progress_falls_back_to_mcp_when_cli_binary_is_missing() -> None:
|
|
|
70
70
|
"gsd_progress",
|
|
71
71
|
{"projectDir": "/tmp/p"},
|
|
72
72
|
check_version=False,
|
|
73
|
+
project_dir="/tmp/p",
|
|
73
74
|
)
|
|
74
75
|
|
|
75
76
|
|
|
@@ -90,6 +91,7 @@ def test_progress_falls_back_to_mcp_when_cli_version_is_unsupported() -> None:
|
|
|
90
91
|
"gsd_progress",
|
|
91
92
|
{"projectDir": "/tmp/p"},
|
|
92
93
|
check_version=False,
|
|
94
|
+
project_dir="/tmp/p",
|
|
93
95
|
)
|
|
94
96
|
|
|
95
97
|
|
|
@@ -117,4 +119,5 @@ def test_progress_falls_back_to_mcp_when_cli_json_has_unexpected_shape() -> None
|
|
|
117
119
|
"gsd_progress",
|
|
118
120
|
{"projectDir": "/tmp/p"},
|
|
119
121
|
check_version=False,
|
|
122
|
+
project_dir="/tmp/p",
|
|
120
123
|
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Unit tests for MCP-backed GSD execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from open_gsd_hermes.config import GsdConfig
|
|
8
|
+
from open_gsd_hermes.gsd_client import GsdMcpClient, GsdVersionError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_execute_falls_back_to_mcp_when_cli_version_is_unsupported() -> None:
|
|
12
|
+
client = GsdMcpClient(GsdConfig())
|
|
13
|
+
|
|
14
|
+
with patch.object(client, "_call_tool") as call_tool:
|
|
15
|
+
call_tool.side_effect = [
|
|
16
|
+
GsdVersionError("old gsd"),
|
|
17
|
+
{"sessionId": "session-1"},
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
result = client.execute("/tmp/p")
|
|
21
|
+
|
|
22
|
+
assert result == {"sessionId": "session-1"}
|
|
23
|
+
assert call_tool.call_args_list[0].args == (
|
|
24
|
+
"gsd_execute",
|
|
25
|
+
{"projectDir": "/tmp/p", "command": "/gsd auto"},
|
|
26
|
+
)
|
|
27
|
+
assert call_tool.call_args_list[0].kwargs == {"project_dir": "/tmp/p"}
|
|
28
|
+
assert call_tool.call_args_list[1].args == (
|
|
29
|
+
"gsd_execute",
|
|
30
|
+
{"projectDir": "/tmp/p", "command": "/gsd auto"},
|
|
31
|
+
)
|
|
32
|
+
assert call_tool.call_args_list[1].kwargs == {
|
|
33
|
+
"check_version": False,
|
|
34
|
+
"project_dir": "/tmp/p",
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Unit tests for MCP server process cwd selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
from open_gsd_hermes.config import GsdConfig
|
|
8
|
+
from open_gsd_hermes.gsd_client import GsdMcpClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_ensure_process_starts_server_in_project_cwd(tmp_path) -> None:
|
|
12
|
+
client = GsdMcpClient(GsdConfig())
|
|
13
|
+
fake_proc = MagicMock()
|
|
14
|
+
fake_proc.poll.return_value = None
|
|
15
|
+
|
|
16
|
+
with patch.object(client, "_initialize") as initialize:
|
|
17
|
+
with patch("subprocess.Popen", return_value=fake_proc) as popen:
|
|
18
|
+
proc = client._ensure_process(str(tmp_path))
|
|
19
|
+
|
|
20
|
+
assert proc is fake_proc
|
|
21
|
+
initialize.assert_called_once_with()
|
|
22
|
+
assert popen.call_args.kwargs["cwd"] == str(tmp_path)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Unit tests for MCP stdio read timeouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from open_gsd_hermes.config import GsdConfig
|
|
12
|
+
from open_gsd_hermes.gsd_client import GsdMcpClient, McpProtocolError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_read_message_times_out_waiting_for_headers() -> None:
|
|
16
|
+
client = GsdMcpClient(GsdConfig(mcp_read_timeout_seconds=0.05))
|
|
17
|
+
proc = subprocess.Popen(
|
|
18
|
+
[sys.executable, "-c", "import time; time.sleep(10)"],
|
|
19
|
+
stdin=subprocess.PIPE,
|
|
20
|
+
stdout=subprocess.PIPE,
|
|
21
|
+
stderr=subprocess.PIPE,
|
|
22
|
+
)
|
|
23
|
+
client._proc = proc
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
with pytest.raises(McpProtocolError, match="timed out"):
|
|
27
|
+
client._read_message()
|
|
28
|
+
assert client._proc is None
|
|
29
|
+
assert proc.poll() is not None
|
|
30
|
+
finally:
|
|
31
|
+
proc.kill()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_read_message_times_out_waiting_for_body() -> None:
|
|
35
|
+
client = GsdMcpClient(GsdConfig(mcp_read_timeout_seconds=0.05))
|
|
36
|
+
proc = subprocess.Popen(
|
|
37
|
+
[
|
|
38
|
+
sys.executable,
|
|
39
|
+
"-c",
|
|
40
|
+
(
|
|
41
|
+
"import sys, time; "
|
|
42
|
+
"sys.stdout.buffer.write(b'Content-Length: 2\\r\\n\\r\\n{'); "
|
|
43
|
+
"sys.stdout.flush(); "
|
|
44
|
+
"time.sleep(10)"
|
|
45
|
+
),
|
|
46
|
+
],
|
|
47
|
+
stdin=subprocess.PIPE,
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.PIPE,
|
|
50
|
+
)
|
|
51
|
+
client._proc = proc
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
with pytest.raises(McpProtocolError, match="timed out"):
|
|
55
|
+
client._read_message()
|
|
56
|
+
assert client._proc is None
|
|
57
|
+
assert proc.poll() is not None
|
|
58
|
+
finally:
|
|
59
|
+
proc.kill()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_read_message_fails_without_respawning_when_stdout_closes_mid_body(
|
|
63
|
+
tmp_path,
|
|
64
|
+
) -> None:
|
|
65
|
+
client = GsdMcpClient(GsdConfig(mcp_read_timeout_seconds=1))
|
|
66
|
+
proc = subprocess.Popen(
|
|
67
|
+
[
|
|
68
|
+
sys.executable,
|
|
69
|
+
"-c",
|
|
70
|
+
(
|
|
71
|
+
"import sys; "
|
|
72
|
+
"sys.stdout.buffer.write(b'Content-Length: 20\\r\\n\\r\\n{'); "
|
|
73
|
+
"sys.stdout.flush()"
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
stdin=subprocess.PIPE,
|
|
77
|
+
stdout=subprocess.PIPE,
|
|
78
|
+
stderr=subprocess.PIPE,
|
|
79
|
+
)
|
|
80
|
+
client._proc = proc
|
|
81
|
+
client._proc_cwd = str(tmp_path)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with patch.object(
|
|
85
|
+
client,
|
|
86
|
+
"_ensure_process",
|
|
87
|
+
side_effect=AssertionError("read must not respawn MCP server"),
|
|
88
|
+
) as ensure_process:
|
|
89
|
+
with pytest.raises(McpProtocolError, match="closed stdout"):
|
|
90
|
+
client._read_message()
|
|
91
|
+
|
|
92
|
+
ensure_process.assert_not_called()
|
|
93
|
+
assert client._proc is None
|
|
94
|
+
assert client._proc_cwd is None
|
|
95
|
+
finally:
|
|
96
|
+
if proc.poll() is None:
|
|
97
|
+
proc.kill()
|
|
98
|
+
proc.wait(timeout=1)
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Tests for GsdMcpClient milestone creation (subprocess-owned, stream-driven).
|
|
2
|
+
|
|
3
|
+
These tests pin the A1 design from issue #1162's grilling:
|
|
4
|
+
- create_milestone spawns `gsd headless --supervised new-milestone`
|
|
5
|
+
- a background stream-reader drives NotificationService on blocker/terminal events
|
|
6
|
+
- cancel_milestone SIGTERMs the held subprocess
|
|
7
|
+
- respond_to_milestone_blocker writes an extension_ui_response to stdin
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
from unittest.mock import MagicMock, patch
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from open_gsd_hermes.config import GsdConfig
|
|
20
|
+
from open_gsd_hermes.gsd_client import GsdMcpClient
|
|
21
|
+
from open_gsd_hermes.types import ProgressSnapshot
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _config() -> GsdConfig:
|
|
25
|
+
return GsdConfig(cli_path="/usr/local/bin/gsd")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# create_milestone — argument construction
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _FakeProc:
|
|
34
|
+
"""Minimal stand-in for subprocess.Popen used by create_milestone."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.pid = 4242
|
|
38
|
+
self.stdin = MagicMock()
|
|
39
|
+
self.stdout = MagicMock()
|
|
40
|
+
self.stderr = BytesIO()
|
|
41
|
+
self.poll = MagicMock(return_value=None)
|
|
42
|
+
self.terminate = MagicMock()
|
|
43
|
+
self.wait = MagicMock(return_value=0)
|
|
44
|
+
self.kill = MagicMock()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_create_milestone_builds_supervised_new_milestone_command_with_context_text() -> None:
|
|
48
|
+
"""create_milestone must invoke `gsd headless --supervised new-milestone --context-text <spec>`."""
|
|
49
|
+
client = GsdMcpClient(_config())
|
|
50
|
+
fake_proc = _FakeProc()
|
|
51
|
+
|
|
52
|
+
captured: dict[str, object] = {}
|
|
53
|
+
|
|
54
|
+
def fake_popen(args, **kwargs): # type: ignore[no-untyped-def]
|
|
55
|
+
captured["args"] = args
|
|
56
|
+
captured["cwd"] = kwargs.get("cwd")
|
|
57
|
+
captured["env"] = kwargs.get("env")
|
|
58
|
+
return fake_proc
|
|
59
|
+
|
|
60
|
+
with patch.object(client, "ensure_version"):
|
|
61
|
+
with patch("subprocess.Popen", side_effect=fake_popen):
|
|
62
|
+
with patch.object(client, "_milestone_stream_loop"):
|
|
63
|
+
client.create_milestone("/proj", context_text="Build a REST API")
|
|
64
|
+
|
|
65
|
+
args = captured["args"]
|
|
66
|
+
assert args[0] == "/usr/local/bin/gsd"
|
|
67
|
+
assert "headless" in args
|
|
68
|
+
assert "--supervised" in args
|
|
69
|
+
assert "--response-timeout" in args
|
|
70
|
+
timeout_idx = args.index("--response-timeout")
|
|
71
|
+
assert args[timeout_idx + 1] == str(GsdMcpClient._SUPERVISED_RESPONSE_TIMEOUT_MS)
|
|
72
|
+
assert "new-milestone" in args
|
|
73
|
+
assert "--context-text" in args
|
|
74
|
+
text_idx = args.index("--context-text")
|
|
75
|
+
assert args[text_idx + 1] == "Build a REST API"
|
|
76
|
+
assert captured["cwd"] == "/proj"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_create_milestone_builds_command_with_context_file() -> None:
|
|
80
|
+
"""--file form passes --context <path> instead of --context-text."""
|
|
81
|
+
client = GsdMcpClient(_config())
|
|
82
|
+
fake_proc = _FakeProc()
|
|
83
|
+
captured: dict[str, object] = {}
|
|
84
|
+
|
|
85
|
+
def fake_popen(args, **kwargs): # type: ignore[no-untyped-def]
|
|
86
|
+
captured["args"] = args
|
|
87
|
+
return fake_proc
|
|
88
|
+
|
|
89
|
+
with patch.object(client, "ensure_version"):
|
|
90
|
+
with patch("subprocess.Popen", side_effect=fake_popen):
|
|
91
|
+
with patch.object(client, "_milestone_stream_loop"):
|
|
92
|
+
client.create_milestone("/proj", context_file="/path/to/spec.md")
|
|
93
|
+
|
|
94
|
+
args = captured["args"]
|
|
95
|
+
assert "--context" in args
|
|
96
|
+
ctx_idx = args.index("--context")
|
|
97
|
+
assert args[ctx_idx + 1] == "/path/to/spec.md"
|
|
98
|
+
assert "--context-text" not in args
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_create_milestone_requires_either_context_text_or_file() -> None:
|
|
102
|
+
"""No spec at all is a usage error, raised before any subprocess spawns."""
|
|
103
|
+
client = GsdMcpClient(_config())
|
|
104
|
+
with pytest.raises(ValueError, match="spec"):
|
|
105
|
+
client.create_milestone("/proj")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_create_milestone_rejects_both_context_text_and_file() -> None:
|
|
109
|
+
"""Ambiguous input (both forms) is a usage error."""
|
|
110
|
+
client = GsdMcpClient(_config())
|
|
111
|
+
with pytest.raises(ValueError, match="spec"):
|
|
112
|
+
client.create_milestone(
|
|
113
|
+
"/proj", context_text="x", context_file="/p.md"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Stream reader — sessionId capture, blocker, terminal
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_create_milestone_returns_local_session_id_from_pid() -> None:
|
|
123
|
+
"""create_milestone returns a local session id without waiting for init_result."""
|
|
124
|
+
client = GsdMcpClient(_config())
|
|
125
|
+
fake_proc = _FakeProc()
|
|
126
|
+
|
|
127
|
+
with patch.object(client, "ensure_version"):
|
|
128
|
+
with patch("subprocess.Popen", return_value=fake_proc):
|
|
129
|
+
with patch.object(client, "_milestone_stream_loop"):
|
|
130
|
+
session_id = client.create_milestone("/proj", context_text="spec")
|
|
131
|
+
|
|
132
|
+
assert session_id == "milestone-4242"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_stream_loop_captures_session_id_from_init_result() -> None:
|
|
136
|
+
"""The reader must pull sessionId out of the init_result stream event."""
|
|
137
|
+
client = GsdMcpClient(_config())
|
|
138
|
+
client._handle_milestone_event({"type": "init_result", "sessionId": "S-789"})
|
|
139
|
+
|
|
140
|
+
assert client.milestone_session_id() == "S-789"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_stream_loop_notifies_blocker_on_supervised_select_request() -> None:
|
|
144
|
+
"""Supervised planning prompts (select/input/confirm) must fire notify_blocker."""
|
|
145
|
+
client = GsdMcpClient(_config())
|
|
146
|
+
notifications = MagicMock()
|
|
147
|
+
blocker_event = {
|
|
148
|
+
"type": "extension_ui_request",
|
|
149
|
+
"method": "select",
|
|
150
|
+
"id": "sel-1",
|
|
151
|
+
"title": "Choose milestone scope",
|
|
152
|
+
"options": ["A", "B"],
|
|
153
|
+
}
|
|
154
|
+
with patch.object(client, "_milestone_notifications", notifications):
|
|
155
|
+
client._handle_milestone_event(blocker_event)
|
|
156
|
+
|
|
157
|
+
notifications.notify_blocker.assert_called_once()
|
|
158
|
+
assert client.milestone_pending_blocker_id() == "sel-1"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_stream_loop_notifies_blocker_on_extension_ui_request() -> None:
|
|
162
|
+
"""Blocked-notice notify events are fire-and-forget; they must not prompt /gsd reply."""
|
|
163
|
+
client = GsdMcpClient(_config())
|
|
164
|
+
notifications = MagicMock()
|
|
165
|
+
# Realistic blocked-notice message — headless auto-acks notify in supervised mode.
|
|
166
|
+
blocker_event = {
|
|
167
|
+
"type": "extension_ui_request",
|
|
168
|
+
"method": "notify",
|
|
169
|
+
"id": "blk-1",
|
|
170
|
+
"message": "Auto-mode paused — blocked: choose an option",
|
|
171
|
+
}
|
|
172
|
+
with patch.object(client, "_milestone_notifications", notifications):
|
|
173
|
+
client._handle_milestone_event(blocker_event)
|
|
174
|
+
|
|
175
|
+
notifications.notify_blocker.assert_not_called()
|
|
176
|
+
assert client.milestone_pending_blocker_id() is None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_build_milestone_completion_message_uses_query_and_progress() -> None:
|
|
180
|
+
client = GsdMcpClient(_config())
|
|
181
|
+
with patch.object(
|
|
182
|
+
client,
|
|
183
|
+
"project_query",
|
|
184
|
+
return_value={"milestones": [{"id": "M002", "hasRoadmap": True}]},
|
|
185
|
+
):
|
|
186
|
+
with patch.object(
|
|
187
|
+
client,
|
|
188
|
+
"progress",
|
|
189
|
+
return_value=ProgressSnapshot(
|
|
190
|
+
active_milestone={"id": "M002"},
|
|
191
|
+
slices={"total": 3},
|
|
192
|
+
tasks={"total": 9},
|
|
193
|
+
),
|
|
194
|
+
):
|
|
195
|
+
msg = client._build_milestone_completion_message("/proj")
|
|
196
|
+
|
|
197
|
+
assert msg == "✅ Milestone M002 ready — 3 slices, 9 tasks. Run `/gsd auto` to start."
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_build_milestone_completion_message_falls_back_when_query_empty() -> None:
|
|
201
|
+
client = GsdMcpClient(_config())
|
|
202
|
+
with patch.object(client, "project_query", return_value={"milestones": []}):
|
|
203
|
+
msg = client._build_milestone_completion_message("/proj")
|
|
204
|
+
|
|
205
|
+
assert msg == "✅ Milestone created. Run `/gsd status` for details."
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_stream_loop_notifies_terminal_on_process_exit() -> None:
|
|
209
|
+
"""When the stream ends (process exited), a milestone completion notification fires."""
|
|
210
|
+
client = GsdMcpClient(_config())
|
|
211
|
+
notifications = MagicMock()
|
|
212
|
+
fake_proc = _FakeProc()
|
|
213
|
+
fake_proc.poll.return_value = 0
|
|
214
|
+
fake_proc.stdout.readline.side_effect = [b""]
|
|
215
|
+
client._milestone_session_id = "milestone-4242"
|
|
216
|
+
client._milestone_pending_blocker_id = "old-blocker"
|
|
217
|
+
client._milestone_pending_blocker_method = "input"
|
|
218
|
+
|
|
219
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
220
|
+
with patch.object(client, "_milestone_project_dir", "/proj"):
|
|
221
|
+
with patch.object(
|
|
222
|
+
client,
|
|
223
|
+
"_build_milestone_completion_message",
|
|
224
|
+
return_value="✅ Milestone M001 ready — 2 slices, 5 tasks. Run `/gsd auto` to start.",
|
|
225
|
+
):
|
|
226
|
+
with patch.object(client, "_milestone_notifications", notifications):
|
|
227
|
+
client._milestone_stream_loop(fake_proc)
|
|
228
|
+
|
|
229
|
+
notifications.notify_milestone_complete.assert_called_once_with(
|
|
230
|
+
"✅ Milestone M001 ready — 2 slices, 5 tasks. Run `/gsd auto` to start."
|
|
231
|
+
)
|
|
232
|
+
notifications.notify_terminal.assert_not_called()
|
|
233
|
+
assert client.milestone_active() is False
|
|
234
|
+
assert client.milestone_session_id() is None
|
|
235
|
+
assert client.milestone_pending_blocker_id() is None
|
|
236
|
+
assert client._milestone_pending_blocker_method is None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_stream_loop_failure_reports_drained_stderr() -> None:
|
|
240
|
+
"""Failure notifications must use stderr retained by the drain thread."""
|
|
241
|
+
client = GsdMcpClient(_config())
|
|
242
|
+
notifications = MagicMock()
|
|
243
|
+
fake_proc = _FakeProc()
|
|
244
|
+
fake_proc.poll.return_value = 2
|
|
245
|
+
fake_proc.stderr = BytesIO(b"fatal diagnostics\n")
|
|
246
|
+
fake_proc.stdout.readline.side_effect = [b""]
|
|
247
|
+
|
|
248
|
+
client._drain_milestone_stderr(fake_proc)
|
|
249
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
250
|
+
with patch.object(client, "_milestone_notifications", notifications):
|
|
251
|
+
client._milestone_stream_loop(fake_proc)
|
|
252
|
+
|
|
253
|
+
notifications.notify_terminal.assert_called_once_with(
|
|
254
|
+
"failed", "fatal diagnostics"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_stream_loop_releases_blocked_exit_with_pending_blocker() -> None:
|
|
259
|
+
"""Exit 10 after stdout EOF is terminal even with a pending blocker id."""
|
|
260
|
+
client = GsdMcpClient(_config())
|
|
261
|
+
notifications = MagicMock()
|
|
262
|
+
fake_proc = _FakeProc()
|
|
263
|
+
fake_proc.poll.return_value = 10
|
|
264
|
+
fake_proc.stdout.readline.side_effect = [b""]
|
|
265
|
+
client._milestone_session_id = "milestone-4242"
|
|
266
|
+
|
|
267
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
268
|
+
with patch.object(client, "_milestone_pending_blocker_id", "sel-1"):
|
|
269
|
+
with patch.object(client, "_milestone_notifications", notifications):
|
|
270
|
+
client._milestone_stream_loop(fake_proc)
|
|
271
|
+
|
|
272
|
+
notifications.notify_terminal.assert_called_once_with("failed", "Planning blocked")
|
|
273
|
+
assert client.milestone_active() is False
|
|
274
|
+
assert client.milestone_session_id() is None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_stream_loop_releases_noninteractive_blocked_exit() -> None:
|
|
278
|
+
"""Exit 10 without a pending blocker must not leave milestone ownership stuck."""
|
|
279
|
+
client = GsdMcpClient(_config())
|
|
280
|
+
notifications = MagicMock()
|
|
281
|
+
fake_proc = _FakeProc()
|
|
282
|
+
fake_proc.poll.return_value = 10
|
|
283
|
+
fake_proc.stdout.readline.side_effect = [b""]
|
|
284
|
+
client._milestone_session_id = "milestone-4242"
|
|
285
|
+
|
|
286
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
287
|
+
with patch.object(client, "_milestone_notifications", notifications):
|
|
288
|
+
client._milestone_stream_loop(fake_proc)
|
|
289
|
+
|
|
290
|
+
notifications.notify_terminal.assert_called_once_with("failed", "Planning blocked")
|
|
291
|
+
assert client.milestone_active() is False
|
|
292
|
+
assert client.milestone_session_id() is None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_stream_loop_retains_running_proc_on_stream_loss() -> None:
|
|
296
|
+
"""If stdout closes while the child is alive, /gsd cancel must still work."""
|
|
297
|
+
client = GsdMcpClient(_config())
|
|
298
|
+
notifications = MagicMock()
|
|
299
|
+
on_terminal = MagicMock()
|
|
300
|
+
fake_proc = _FakeProc()
|
|
301
|
+
fake_proc.poll.return_value = None
|
|
302
|
+
fake_proc.wait.side_effect = subprocess.TimeoutExpired(cmd="gsd", timeout=5)
|
|
303
|
+
fake_proc.stdout.readline.side_effect = [b""]
|
|
304
|
+
client._milestone_session_id = "milestone-4242"
|
|
305
|
+
|
|
306
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
307
|
+
with patch.object(client, "_milestone_notifications", notifications):
|
|
308
|
+
with patch.object(client, "_milestone_on_terminal", on_terminal):
|
|
309
|
+
client._milestone_stream_loop(fake_proc)
|
|
310
|
+
|
|
311
|
+
notifications.notify_terminal.assert_called_once_with(
|
|
312
|
+
"failed", "stream lost — run /gsd cancel"
|
|
313
|
+
)
|
|
314
|
+
on_terminal.assert_called_once_with("failed")
|
|
315
|
+
assert client.milestone_active() is True
|
|
316
|
+
assert client.milestone_session_id() == "milestone-4242"
|
|
317
|
+
|
|
318
|
+
client.cancel_milestone()
|
|
319
|
+
|
|
320
|
+
fake_proc.terminate.assert_called_once()
|
|
321
|
+
assert client.milestone_active() is False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_stream_loop_handles_blocking_command_block() -> None:
|
|
325
|
+
"""gsd-command-block failures must not be reported as milestone complete."""
|
|
326
|
+
client = GsdMcpClient(_config())
|
|
327
|
+
notifications = MagicMock()
|
|
328
|
+
fake_proc = _FakeProc()
|
|
329
|
+
client._milestone_session_id = "milestone-4242"
|
|
330
|
+
command_block = {
|
|
331
|
+
"type": "message_start",
|
|
332
|
+
"message": {
|
|
333
|
+
"role": "custom",
|
|
334
|
+
"customType": "gsd-command-block",
|
|
335
|
+
"content": (
|
|
336
|
+
"/gsd auto cannot run because the active milestone "
|
|
337
|
+
"is blocked by validation."
|
|
338
|
+
),
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
fake_proc.poll.return_value = 0
|
|
342
|
+
fake_proc.stdout.readline.side_effect = [
|
|
343
|
+
(json.dumps(command_block) + "\n").encode(),
|
|
344
|
+
b"",
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
348
|
+
with patch.object(client, "_milestone_notifications", notifications):
|
|
349
|
+
client._milestone_stream_loop(fake_proc)
|
|
350
|
+
|
|
351
|
+
notifications.notify_terminal.assert_called_with(
|
|
352
|
+
"failed",
|
|
353
|
+
"/gsd auto cannot run because the active milestone is blocked by validation.",
|
|
354
|
+
)
|
|
355
|
+
notifications.notify_milestone_complete.assert_not_called()
|
|
356
|
+
assert client.milestone_active() is False
|
|
357
|
+
assert client.milestone_session_id() is None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# cancel_milestone / respond_to_milestone_blocker / milestone_active
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_cancel_milestone_terminates_held_subprocess() -> None:
|
|
366
|
+
client = GsdMcpClient(_config())
|
|
367
|
+
fake_proc = _FakeProc()
|
|
368
|
+
|
|
369
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
370
|
+
client.cancel_milestone()
|
|
371
|
+
|
|
372
|
+
fake_proc.terminate.assert_called_once()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_cancel_milestone_when_no_proc_is_a_noop() -> None:
|
|
376
|
+
"""Calling cancel with no active milestone proc must not raise."""
|
|
377
|
+
client = GsdMcpClient(_config())
|
|
378
|
+
# should not raise
|
|
379
|
+
client.cancel_milestone()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_close_terminates_active_milestone_subprocess() -> None:
|
|
383
|
+
"""close() must tear down the owned milestone subprocess as well as MCP."""
|
|
384
|
+
client = GsdMcpClient(_config())
|
|
385
|
+
fake_proc = _FakeProc()
|
|
386
|
+
|
|
387
|
+
with (
|
|
388
|
+
patch.object(client, "_terminate_process"),
|
|
389
|
+
patch.object(client, "_milestone_proc", fake_proc),
|
|
390
|
+
):
|
|
391
|
+
client.close()
|
|
392
|
+
|
|
393
|
+
fake_proc.terminate.assert_called_once()
|
|
394
|
+
assert client.milestone_active() is False
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_respond_to_milestone_blocker_writes_extension_ui_response_to_stdin() -> None:
|
|
398
|
+
"""The reply must write the supervised-mode stdin JSONL protocol."""
|
|
399
|
+
client = GsdMcpClient(_config())
|
|
400
|
+
fake_proc = _FakeProc()
|
|
401
|
+
|
|
402
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
403
|
+
with patch.object(client, "_milestone_pending_blocker_id", "blk-7"):
|
|
404
|
+
with patch.object(client, "_milestone_pending_blocker_method", "input"):
|
|
405
|
+
client.respond_to_milestone_blocker("use option B")
|
|
406
|
+
|
|
407
|
+
assert fake_proc.stdin.write.called
|
|
408
|
+
written = fake_proc.stdin.write.call_args[0][0]
|
|
409
|
+
payload = json.loads(written.decode() if isinstance(written, bytes) else written)
|
|
410
|
+
assert payload["type"] == "extension_ui_response"
|
|
411
|
+
assert payload["id"] == "blk-7"
|
|
412
|
+
assert payload["value"] == "use option B"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_respond_to_milestone_blocker_writes_confirmed_for_confirm_prompt() -> None:
|
|
416
|
+
"""Confirm blockers must answer with a confirmed boolean, not free text."""
|
|
417
|
+
client = GsdMcpClient(_config())
|
|
418
|
+
fake_proc = _FakeProc()
|
|
419
|
+
|
|
420
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
421
|
+
with patch.object(client, "_milestone_pending_blocker_id", "cfm-1"):
|
|
422
|
+
with patch.object(client, "_milestone_pending_blocker_method", "confirm"):
|
|
423
|
+
client.respond_to_milestone_blocker("yes")
|
|
424
|
+
|
|
425
|
+
written = fake_proc.stdin.write.call_args[0][0]
|
|
426
|
+
payload = json.loads(written.decode() if isinstance(written, bytes) else written)
|
|
427
|
+
assert payload["type"] == "extension_ui_response"
|
|
428
|
+
assert payload["id"] == "cfm-1"
|
|
429
|
+
assert payload["confirmed"] is True
|
|
430
|
+
assert "value" not in payload
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_respond_to_milestone_blocker_without_pending_blocker_raises() -> None:
|
|
434
|
+
"""No pending blocker id means nothing to reply to — fail loud, don't pretend."""
|
|
435
|
+
client = GsdMcpClient(_config())
|
|
436
|
+
fake_proc = _FakeProc()
|
|
437
|
+
with patch.object(client, "_milestone_proc", fake_proc):
|
|
438
|
+
with pytest.raises(RuntimeError, match="No pending blocker"):
|
|
439
|
+
client.respond_to_milestone_blocker("answer")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_milestone_active_reflects_held_proc() -> None:
|
|
443
|
+
client = GsdMcpClient(_config())
|
|
444
|
+
assert client.milestone_active() is False
|
|
445
|
+
with patch.object(client, "_milestone_proc", _FakeProc()):
|
|
446
|
+
assert client.milestone_active() is True
|
|
447
|
+
assert client.milestone_active() is False
|