vis-agent 0.2.2__tar.gz → 0.2.3__tar.gz

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.
Files changed (85) hide show
  1. {vis_agent-0.2.2 → vis_agent-0.2.3}/PKG-INFO +8 -3
  2. vis_agent-0.2.3/README.md +16 -0
  3. {vis_agent-0.2.2 → vis_agent-0.2.3}/pyproject.toml +1 -1
  4. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/engine/__init__.py +6 -3
  5. vis_agent-0.2.3/src/blockether/vis/engine/_agent.py +105 -0
  6. vis_agent-0.2.3/tests/test_agent.py +216 -0
  7. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_engine.py +3 -0
  8. vis_agent-0.2.3/tests/test_sdk_guide.py +614 -0
  9. vis_agent-0.2.2/README.md +0 -11
  10. {vis_agent-0.2.2 → vis_agent-0.2.3}/.gitignore +0 -0
  11. {vis_agent-0.2.2 → vis_agent-0.2.3}/LICENSE +0 -0
  12. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/activity.json +0 -0
  13. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/config.json +0 -0
  14. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/content.json +0 -0
  15. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/council.json +0 -0
  16. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/activity-arguments.json +0 -0
  17. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/activity-cases.json +0 -0
  18. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/activity-copy.json +0 -0
  19. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/activity-groups.json +0 -0
  20. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/activity-repl.json +0 -0
  21. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/activity-results.json +0 -0
  22. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/activity-tables.json +0 -0
  23. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/activity.json +0 -0
  24. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/live-primitives.json +0 -0
  25. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/session-health.json +0 -0
  26. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/fixtures/view.json +0 -0
  27. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/gateway.json +0 -0
  28. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/provider.json +0 -0
  29. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/python-host.json +0 -0
  30. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/activity.json +0 -0
  31. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/common.json +0 -0
  32. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/config.json +0 -0
  33. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/content.json +0 -0
  34. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/council.json +0 -0
  35. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/gateway.json +0 -0
  36. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/provider.json +0 -0
  37. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/python-host.json +0 -0
  38. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/surface.json +0 -0
  39. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/symbol.json +0 -0
  40. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/test-runner.json +0 -0
  41. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/toggle.json +0 -0
  42. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/schema/view.json +0 -0
  43. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/surface.json +0 -0
  44. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/symbol.json +0 -0
  45. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/test-runner.json +0 -0
  46. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/toggle.json +0 -0
  47. {vis_agent-0.2.2 → vis_agent-0.2.3}/contracts/view.json +0 -0
  48. {vis_agent-0.2.2 → vis_agent-0.2.3}/examples/greeter/tests/test_greeter.py +0 -0
  49. {vis_agent-0.2.2 → vis_agent-0.2.3}/hatch_build.py +0 -0
  50. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/__init__.py +0 -0
  51. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/_contracts.py +0 -0
  52. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/_outside.py +0 -0
  53. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/_wire.py +0 -0
  54. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/activity.py +0 -0
  55. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/engine/_client.py +0 -0
  56. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/engine/_council.py +0 -0
  57. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/engine/_local.py +0 -0
  58. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/extension.py +0 -0
  59. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/extension_package.py +0 -0
  60. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/py.typed +0 -0
  61. {vis_agent-0.2.2 → vis_agent-0.2.3}/src/blockether/vis/views.py +0 -0
  62. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/conftest.py +0 -0
  63. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_activity.py +0 -0
  64. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_authoring.py +0 -0
  65. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_catalog.py +0 -0
  66. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_catalog_recipe.py +0 -0
  67. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_ci_report_recipe.py +0 -0
  68. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_client.py +0 -0
  69. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_code_quality_recipe.py +0 -0
  70. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_contracts.py +0 -0
  71. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_council.py +0 -0
  72. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_declarations.py +0 -0
  73. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_extension_package.py +0 -0
  74. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_goals.py +0 -0
  75. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_live_primitives.py +0 -0
  76. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_live_wait.py +0 -0
  77. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_local.py +0 -0
  78. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_monitor_recipe.py +0 -0
  79. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_namespace.py +0 -0
  80. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_outside.py +0 -0
  81. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_providers.py +0 -0
  82. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_registration.py +0 -0
  83. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_symbol_contract.py +0 -0
  84. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_testing.py +0 -0
  85. {vis_agent-0.2.2 → vis_agent-0.2.3}/tests/test_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vis-agent
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: The Vis Python SDK — author extensions and control local or remote engines.
5
5
  Project-URL: Homepage, https://vis.blockether.com
6
6
  Project-URL: Documentation, https://vis.blockether.com
@@ -28,6 +28,11 @@ Python SDK for Vis extensions and agent sessions.
28
28
  pip install vis-agent
29
29
  ```
30
30
 
31
- Requires Python 3.11+. The Vis executable is installed separately.
31
+ Requires Python 3.11+. Install the Vis executable and configure a provider
32
+ separately for local use.
32
33
 
33
- [Documentation](https://vis.blockether.com)
34
+ - [Python SDK guide](https://vis.blockether.com/python-sdk.html) — run a local
35
+ agent, continue a conversation or connect to a shared gateway. The new
36
+ `Agent(project=".")` API requires the source SDK, not PyPI `0.2.2`.
37
+ - [Extension guide](https://vis.blockether.com/extending.html) — add tools to Vis.
38
+ - [Documentation](https://vis.blockether.com) — browse all guides and reference pages.
@@ -0,0 +1,16 @@
1
+ # vis-agent
2
+
3
+ Python SDK for Vis extensions and agent sessions.
4
+
5
+ ```sh
6
+ pip install vis-agent
7
+ ```
8
+
9
+ Requires Python 3.11+. Install the Vis executable and configure a provider
10
+ separately for local use.
11
+
12
+ - [Python SDK guide](https://vis.blockether.com/python-sdk.html) — run a local
13
+ agent, continue a conversation or connect to a shared gateway. The new
14
+ `Agent(project=".")` API requires the source SDK, not PyPI `0.2.2`.
15
+ - [Extension guide](https://vis.blockether.com/extending.html) — add tools to Vis.
16
+ - [Documentation](https://vis.blockether.com) — browse all guides and reference pages.
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
8
8
 
9
9
  [project]
10
10
  name = "vis-agent"
11
- version = "0.2.2"
11
+ version = "0.2.3"
12
12
  description = "The Vis Python SDK — author extensions and control local or remote engines."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -1,9 +1,11 @@
1
- """Explicit Vis engine clients, lifecycle records, streams and transport errors.
1
+ """Vis agents, engine clients, lifecycle records, streams and transport errors.
2
2
 
3
- ``GatewayClient`` connects to an existing gateway; ``LocalEngine`` owns a local
4
- stdio process. Importing either starts nothing and never binds an extension host.
3
+ ``Agent`` runs one conversation locally or through an existing gateway.
4
+ ``GatewayClient`` manages gateway sessions; ``LocalEngine`` owns a local stdio
5
+ process. Importing these starts nothing and never binds an extension host.
5
6
  """
6
7
 
8
+ from ._agent import Agent
7
9
  from ._client import (
8
10
  Event,
9
11
  Events,
@@ -33,6 +35,7 @@ from ._council import (
33
35
  from ._local import LocalEngine
34
36
 
35
37
  __all__ = [
38
+ "Agent",
36
39
  "Council",
37
40
  "CouncilEntry",
38
41
  "CouncilKind",
@@ -0,0 +1,105 @@
1
+ """One conversation using an owned stdio engine or an existing HTTP gateway."""
2
+
3
+ from pathlib import Path, PurePosixPath
4
+
5
+ from ._client import GatewayClient, Session, TransportError, Turn
6
+ from ._local import LocalEngine
7
+
8
+
9
+ class Agent:
10
+ """Run tasks locally, or connect to a separately running gateway.
11
+
12
+ Agent() resolves the existing local project directory (default: ".") at
13
+ construction. Context entry, send(), run() or session starts a private
14
+ LocalEngine and one conversation, without an HTTP listener. close() stops
15
+ that process and discards its temporary session database, not file edits.
16
+ Environment, provider credentials and extensions are inherited. Linux/macOS.
17
+
18
+ With gateway_url, project must be an absolute POSIX path on that gateway;
19
+ it is never resolved or checked on the client machine. No local engine is
20
+ needed. The agent creates one app-channel session through GatewayClient.
21
+ close() releases the client lease, not the saved session or remote work.
22
+ The URL and optional bearer token are explicit; no environment discovery.
23
+
24
+ executable (launcher path or argv) and startup_timeout are local-only.
25
+ timeout bounds transport operations in either mode. Instances use one
26
+ calling thread. Use GatewayClient directly to resume or manage sessions.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ project=".",
32
+ *,
33
+ gateway_url=None,
34
+ token=None,
35
+ executable="vis-agent",
36
+ timeout=30,
37
+ startup_timeout=120,
38
+ ):
39
+ if gateway_url is None:
40
+ if token is not None:
41
+ raise ValueError("token requires gateway_url")
42
+ self.project = Path(project).resolve(strict=True)
43
+ if not self.project.is_dir():
44
+ raise NotADirectoryError(str(self.project))
45
+ self._client = LocalEngine(
46
+ executable=executable,
47
+ root=self.project,
48
+ timeout=timeout,
49
+ startup_timeout=startup_timeout,
50
+ )
51
+ self._session_options = {"root": str(self.project)}
52
+ else:
53
+ if executable != "vis-agent" or startup_timeout != 120:
54
+ raise ValueError("executable and startup_timeout are local-only")
55
+ self.project = PurePosixPath(project)
56
+ if not self.project.is_absolute():
57
+ raise ValueError("project must be an absolute path on the gateway")
58
+ self._client = GatewayClient(gateway_url, token=token, timeout=timeout)
59
+ self._session_options = {"root": str(self.project), "channel": "app"}
60
+ self._session = None
61
+ self._closed = False
62
+
63
+ @property
64
+ def session(self) -> Session:
65
+ """The same conversation for all requests; connects on first access."""
66
+ if self._closed:
67
+ raise TransportError("agent is closed")
68
+ if self._session is None:
69
+ try:
70
+ self._client.connect()
71
+ self._session = self._client.create_session(**self._session_options)
72
+ except BaseException:
73
+ self.close()
74
+ raise
75
+ return self._session
76
+
77
+ def send(self, request: str, **options) -> Turn:
78
+ """Submit a request, returning a Turn for progress, waiting or cancellation.
79
+
80
+ Options are passed to Session.send(), including provider, model and
81
+ idempotency_key. Follow-up requests use this agent's existing session.
82
+ """
83
+ return self.session.send(request, **options)
84
+
85
+ def run(self, request: str, *, timeout=300, **options):
86
+ """Submit and wait, returning the canonical turn record, not a text string.
87
+
88
+ Inspect status: failed, cancelled or suspended records are returned too.
89
+ A wait timeout raises VisTimeout without cancelling the turn. Context
90
+ exit stops unfinished local work; remote work can outlive the client.
91
+ """
92
+ return self.send(request, **options).wait(timeout=timeout)
93
+
94
+ def close(self):
95
+ """Close the owned process or gateway client, never a remote session or service."""
96
+ if not self._closed:
97
+ self._closed = True
98
+ self._client.close()
99
+
100
+ def __enter__(self):
101
+ _ = self.session
102
+ return self
103
+
104
+ def __exit__(self, *_):
105
+ self.close()
@@ -0,0 +1,216 @@
1
+ """Agent lifecycle and delegation; real transports live in test_sdk_guide."""
2
+
3
+ from unittest.mock import Mock
4
+
5
+ import blockether.vis.engine as engine
6
+ import pytest
7
+
8
+
9
+ @pytest.fixture
10
+ def local(monkeypatch):
11
+ factory = Mock()
12
+ monkeypatch.setattr("blockether.vis.engine._agent.LocalEngine", factory)
13
+ return factory
14
+
15
+
16
+ def test_default_project_is_resolved_before_chdir(local, monkeypatch, tmp_path):
17
+ monkeypatch.chdir(tmp_path)
18
+ agent = engine.Agent()
19
+ monkeypatch.chdir(tmp_path.parent)
20
+ assert agent.project == tmp_path.resolve()
21
+ local.assert_called_once_with(
22
+ executable="vis-agent", root=tmp_path.resolve(), timeout=30, startup_timeout=120
23
+ )
24
+ local.return_value.connect.assert_not_called()
25
+ with agent:
26
+ assert agent.session is local.return_value.create_session.return_value
27
+ local.return_value.create_session.assert_called_once_with(
28
+ root=str(tmp_path.resolve())
29
+ )
30
+ local.return_value.close.assert_called_once_with()
31
+
32
+
33
+ def test_followups_and_options_use_one_conversation(local, tmp_path):
34
+ with engine.Agent(tmp_path, executable=["custom-vis", "--jvm"]) as agent:
35
+ conversation = agent.session
36
+ result = agent.run("First", timeout=42, provider="provider", model="model")
37
+ conversation.send.assert_called_once_with(
38
+ "First", provider="provider", model="model"
39
+ )
40
+ conversation.send.return_value.wait.assert_called_once_with(timeout=42)
41
+ assert result is conversation.send.return_value.wait.return_value
42
+ turn = agent.send("Follow up", idempotency_key="same-request")
43
+ assert turn is conversation.send.return_value
44
+ conversation.send.assert_called_with(
45
+ "Follow up", idempotency_key="same-request"
46
+ )
47
+ local.return_value.connect.assert_called_once_with()
48
+ local.return_value.create_session.assert_called_once()
49
+
50
+
51
+ @pytest.mark.parametrize("status", ["completed", "failed", "cancelled", "suspended"])
52
+ def test_run_preserves_the_canonical_turn_record(local, tmp_path, status):
53
+ result = {"status": status, "content": [], "turn_id": "turn"}
54
+ local.return_value.create_session.return_value.send.return_value.wait.return_value = result
55
+ with engine.Agent(tmp_path) as agent:
56
+ assert agent.run("Request") is result
57
+
58
+
59
+ @pytest.mark.parametrize("operation", ["connect", "create_session"])
60
+ def test_startup_failure_closes_engine_and_prevents_reuse(local, tmp_path, operation):
61
+ getattr(local.return_value, operation).side_effect = engine.TransportError(
62
+ "startup"
63
+ )
64
+ agent = engine.Agent(tmp_path)
65
+ with pytest.raises(engine.TransportError, match="startup"):
66
+ with agent:
67
+ pytest.fail("startup should not succeed")
68
+ local.return_value.close.assert_called_once_with()
69
+ with pytest.raises(engine.TransportError, match="closed"):
70
+ agent.send("retry")
71
+ agent.close()
72
+ local.return_value.close.assert_called_once_with()
73
+
74
+
75
+ def test_close_without_start_and_body_exception(local, tmp_path):
76
+ agent = engine.Agent(tmp_path)
77
+ agent.close()
78
+ agent.close()
79
+ local.return_value.connect.assert_not_called()
80
+ local.return_value.close.assert_called_once_with()
81
+ with pytest.raises(engine.TransportError, match="closed"):
82
+ with agent:
83
+ pass
84
+ local.reset_mock()
85
+ with pytest.raises(RuntimeError, match="body"):
86
+ with engine.Agent(tmp_path):
87
+ raise RuntimeError("body")
88
+ local.return_value.close.assert_called_once_with()
89
+
90
+
91
+ def test_wait_timeout_does_not_cancel_the_turn(local, tmp_path):
92
+ with engine.Agent(tmp_path) as agent:
93
+ turn = agent.session.send.return_value
94
+ turn.wait.side_effect = engine.VisTimeout("deadline")
95
+ with pytest.raises(engine.VisTimeout, match="deadline"):
96
+ agent.run("Request", timeout=0.1)
97
+ turn.cancel.assert_not_called()
98
+ local.return_value.close.assert_not_called()
99
+ local.return_value.close.assert_called_once_with()
100
+
101
+
102
+ def test_project_must_be_an_existing_directory(tmp_path):
103
+ with pytest.raises(FileNotFoundError):
104
+ engine.Agent(tmp_path / "missing")
105
+ file = tmp_path / "file"
106
+ file.touch()
107
+ with pytest.raises(NotADirectoryError):
108
+ engine.Agent(file)
109
+
110
+
111
+ def test_missing_launcher_has_no_surviving_process(tmp_path):
112
+ agent = engine.Agent(tmp_path, executable=str(tmp_path / "missing"))
113
+ with pytest.raises(engine.TransportError):
114
+ with agent:
115
+ pass
116
+ agent.close()
117
+
118
+
119
+ @pytest.fixture
120
+ def remote(monkeypatch):
121
+ factory = Mock()
122
+ monkeypatch.setattr("blockether.vis.engine._agent.GatewayClient", factory)
123
+ return factory
124
+
125
+
126
+ def test_remote_agent_needs_no_local_project_or_engine(local, remote):
127
+ agent = engine.Agent(
128
+ "/srv/vis-project",
129
+ gateway_url="https://gateway.example.com",
130
+ token="test-token",
131
+ timeout=12,
132
+ )
133
+ assert str(agent.project) == "/srv/vis-project"
134
+ local.assert_not_called()
135
+ remote.assert_called_once_with(
136
+ "https://gateway.example.com", token="test-token", timeout=12
137
+ )
138
+ remote.return_value.connect.assert_not_called()
139
+ with agent:
140
+ conversation = agent.session
141
+ remote.return_value.create_session.assert_called_once_with(
142
+ root="/srv/vis-project", channel="app"
143
+ )
144
+ result = agent.run("First", timeout=42, provider="provider", model="model")
145
+ assert result is conversation.send.return_value.wait.return_value
146
+ conversation.send.assert_called_once_with(
147
+ "First", provider="provider", model="model"
148
+ )
149
+ conversation.send.return_value.wait.assert_called_once_with(timeout=42)
150
+ assert agent.send("Follow up") is conversation.send.return_value
151
+ conversation.send.assert_called_with("Follow up")
152
+ remote.return_value.connect.assert_called_once_with()
153
+ remote.return_value.create_session.assert_called_once()
154
+ remote.return_value.close.assert_called_once_with()
155
+ conversation.delete.assert_not_called()
156
+ conversation.send.return_value.cancel.assert_not_called()
157
+ agent.close()
158
+ remote.return_value.close.assert_called_once_with()
159
+ with pytest.raises(engine.TransportError, match="closed"):
160
+ agent.send("Retry")
161
+
162
+
163
+ @pytest.mark.parametrize("operation", ["connect", "create_session"])
164
+ def test_remote_startup_failure_releases_client(remote, operation):
165
+ getattr(remote.return_value, operation).side_effect = engine.TransportError(
166
+ "startup"
167
+ )
168
+ agent = engine.Agent("/srv/vis-project", gateway_url="https://gateway.example.com")
169
+ with pytest.raises(engine.TransportError, match="startup"):
170
+ with agent:
171
+ pytest.fail("startup should not succeed")
172
+ remote.return_value.close.assert_called_once_with()
173
+ with pytest.raises(engine.TransportError, match="closed"):
174
+ agent.run("Retry")
175
+
176
+
177
+ def test_remote_wait_timeout_and_context_error_only_close_client(remote):
178
+ with pytest.raises(engine.VisTimeout, match="deadline"):
179
+ with engine.Agent(
180
+ "/srv/vis-project", gateway_url="https://gateway.example.com"
181
+ ) as agent:
182
+ turn = agent.session.send.return_value
183
+ turn.wait.side_effect = engine.VisTimeout("deadline")
184
+ agent.run("Request", timeout=0.1)
185
+ turn.cancel.assert_not_called()
186
+ remote.return_value.close.assert_called_once_with()
187
+ remote.return_value.create_session.return_value.delete.assert_not_called()
188
+
189
+
190
+ def test_remote_close_before_connect(remote):
191
+ agent = engine.Agent("/srv/vis-project", gateway_url="https://gateway.example.com")
192
+ agent.close()
193
+ agent.close()
194
+ remote.return_value.connect.assert_not_called()
195
+ remote.return_value.close.assert_called_once_with()
196
+
197
+
198
+ @pytest.mark.parametrize("project", [".", "relative/project", "", "~/project"])
199
+ def test_remote_project_must_be_explicit_absolute_path(local, project):
200
+ with pytest.raises(ValueError, match="absolute.*gateway"):
201
+ engine.Agent(project, gateway_url="https://gateway.example.com")
202
+ local.assert_not_called()
203
+
204
+
205
+ @pytest.mark.parametrize(
206
+ "options",
207
+ [
208
+ {"token": "test-token"},
209
+ {"gateway_url": "https://gateway.example.com", "executable": "custom-vis"},
210
+ {"gateway_url": "https://gateway.example.com", "startup_timeout": 10},
211
+ ],
212
+ )
213
+ def test_mixed_transport_options_are_rejected(local, tmp_path, options):
214
+ with pytest.raises(ValueError):
215
+ engine.Agent(tmp_path, **options)
216
+ local.assert_not_called()
@@ -247,6 +247,7 @@ def sdk_fixture(
247
247
  council=False,
248
248
  tool_code=None,
249
249
  before_reply=None,
250
+ project_extensions=None,
250
251
  ):
251
252
  raw = os.environ.get("VIS_TEST_LOCAL_COMMAND")
252
253
  if not raw:
@@ -258,6 +259,8 @@ def sdk_fixture(
258
259
  extensions = work / ".vis" / "extensions"
259
260
  extensions.mkdir(parents=True)
260
261
  (extensions / "fixture.py").write_text(EXTENSION)
262
+ for name, source in (project_extensions or {}).items():
263
+ (extensions / name).write_text(source)
261
264
  monkeypatch.setenv("HOME", str(home))
262
265
  monkeypatch.setenv("JAVA_TOOL_OPTIONS", f"-Duser.home={home}")
263
266
  monkeypatch.delenv("VIS_GATEWAY_URL", raising=False)