ttasks 0.3.1__tar.gz → 0.4.0__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.
- {ttasks-0.3.1 → ttasks-0.4.0}/PKG-INFO +1 -1
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/quickstart.md +20 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/__init__.py +2 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_version.py +2 -2
- ttasks-0.4.0/src/ttasks/copilot.py +329 -0
- ttasks-0.4.0/tests/test_copilot_session.py +549 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_e2e.py +52 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_executor.py +16 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_public_api.py +3 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/.github/workflows/docs.yml +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/.github/workflows/publish.yml +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/.gitignore +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/.python-version +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/.vscode/launch.json +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/.vscode/settings.json +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/README.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/index.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/patterns/finally-tasks.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/patterns/progress-and-output.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/patterns/retries-and-cancellation.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/reference/api.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/tutorials/async-execution.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/tutorials/graph-workflows.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/docs/tutorials/task-execution.md +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/examples/finally_tasks.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/main.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/mkdocs.yml +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/pyproject.toml +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/scripts/preflight.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_events.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_exceptions.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_executor.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_graph.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_sqlite.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_store.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_task.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/py.typed +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/conftest.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_events.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_sqlite_store.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_store.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_task.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_task_factories.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_workflow.py +0 -0
- {ttasks-0.3.1 → ttasks-0.4.0}/uv.lock +0 -0
|
@@ -19,6 +19,26 @@ assert task.result is result
|
|
|
19
19
|
and Copilot agent tasks. You can override any handler or create an executor with
|
|
20
20
|
no defaults by using `TaskExecutor.empty()`.
|
|
21
21
|
|
|
22
|
+
## Share a Copilot agent session
|
|
23
|
+
|
|
24
|
+
The default Copilot agent handler creates a fresh Copilot session for each task.
|
|
25
|
+
Use `CopilotAgentSession` when multiple `Task.agent(...)` tasks should share one
|
|
26
|
+
conversation:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from ttasks import CopilotAgentSession, Task, TaskExecutor, TaskType
|
|
30
|
+
|
|
31
|
+
with CopilotAgentSession(working_directory="/path/to/repo") as agent:
|
|
32
|
+
executor = TaskExecutor()
|
|
33
|
+
executor.register(TaskType.AGENT, agent.handler())
|
|
34
|
+
|
|
35
|
+
executor.execute(Task.agent("Create a first change."))
|
|
36
|
+
executor.execute(Task.agent("Continue from the previous change."))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Shared sessions preserve conversation state across agent tasks. The handler
|
|
40
|
+
serializes turns through the session, including when used by `TaskGraph`.
|
|
41
|
+
|
|
22
42
|
## Run a graph
|
|
23
43
|
|
|
24
44
|
```python
|
|
@@ -20,12 +20,14 @@ from ._store import (
|
|
|
20
20
|
Store,
|
|
21
21
|
)
|
|
22
22
|
from ._task import Task, TaskResult, TaskStatus, TaskType
|
|
23
|
+
from .copilot import CopilotAgentSession
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"EventBus",
|
|
26
27
|
"InMemoryStore",
|
|
27
28
|
"SQLiteStore",
|
|
28
29
|
"Store",
|
|
30
|
+
"CopilotAgentSession",
|
|
29
31
|
"RetryPolicy",
|
|
30
32
|
"Task",
|
|
31
33
|
"TaskCancelled",
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Copilot-backed task handlers and session helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from concurrent.futures import CancelledError as FutureCancelledError
|
|
8
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
9
|
+
from threading import Event, Lock, Thread
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
from ._exceptions import TaskCancelled
|
|
13
|
+
from ._executor import DEFAULT_COPILOT_AGENT_MODEL, TaskContext, TaskHandler
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CopilotAgentSession:
|
|
17
|
+
"""Long-lived Copilot agent session with a ``TaskExecutor`` handler.
|
|
18
|
+
|
|
19
|
+
A shared session preserves Copilot conversation state across multiple
|
|
20
|
+
``Task.agent(...)`` executions. Use a fresh ``CopilotAgentSession`` for an
|
|
21
|
+
independent conversational lane.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
model: str = DEFAULT_COPILOT_AGENT_MODEL,
|
|
28
|
+
reasoning_effort: str | None = None,
|
|
29
|
+
working_directory: str | None = None,
|
|
30
|
+
timeout: float | None = None,
|
|
31
|
+
on_event: Callable[[Any], None] | None = None,
|
|
32
|
+
**session_options: Any,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Configure a reusable Copilot agent session.
|
|
35
|
+
|
|
36
|
+
``session_options`` are passed through to
|
|
37
|
+
``CopilotClient.create_session(...)``. Permission requests default to
|
|
38
|
+
``PermissionHandler.approve_all`` to match the existing one-shot AGENT
|
|
39
|
+
handler unless ``on_permission_request`` is supplied.
|
|
40
|
+
"""
|
|
41
|
+
if not model:
|
|
42
|
+
raise ValueError("model must not be empty")
|
|
43
|
+
if timeout is not None and timeout <= 0:
|
|
44
|
+
raise ValueError("timeout must be greater than 0")
|
|
45
|
+
|
|
46
|
+
self.model = model
|
|
47
|
+
self.reasoning_effort = reasoning_effort
|
|
48
|
+
self.working_directory = working_directory
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self._session_options = dict(session_options)
|
|
51
|
+
|
|
52
|
+
self._event_handlers: list[Callable[[Any], None]] = []
|
|
53
|
+
self.event_errors: list[BaseException] = []
|
|
54
|
+
self._event_lock = Lock()
|
|
55
|
+
if on_event is not None:
|
|
56
|
+
self._event_handlers.append(on_event)
|
|
57
|
+
|
|
58
|
+
self._client: Any | None = None
|
|
59
|
+
self._session: Any | None = None
|
|
60
|
+
self._send_lock: asyncio.Lock | None = None
|
|
61
|
+
self._active = False
|
|
62
|
+
self._sync_active = False
|
|
63
|
+
|
|
64
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
65
|
+
self._thread: Thread | None = None
|
|
66
|
+
self._thread_ready = Event()
|
|
67
|
+
self._handler_lock = Lock()
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> Self:
|
|
70
|
+
"""Open the Copilot client and session in the current event loop."""
|
|
71
|
+
if self._active:
|
|
72
|
+
raise RuntimeError("CopilotAgentSession is already active")
|
|
73
|
+
await self._open_async()
|
|
74
|
+
self._sync_active = False
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(
|
|
78
|
+
self,
|
|
79
|
+
exc_type: type[BaseException] | None,
|
|
80
|
+
exc: BaseException | None,
|
|
81
|
+
traceback: object | None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Close the Copilot session and client."""
|
|
84
|
+
await self._close_async(exc_type, exc, traceback)
|
|
85
|
+
|
|
86
|
+
def __enter__(self) -> Self:
|
|
87
|
+
"""Open the Copilot client and session on a background event loop."""
|
|
88
|
+
if self._active:
|
|
89
|
+
raise RuntimeError("CopilotAgentSession is already active")
|
|
90
|
+
self._start_loop_thread()
|
|
91
|
+
try:
|
|
92
|
+
self._run_on_loop(self._open_async())
|
|
93
|
+
except BaseException:
|
|
94
|
+
self._stop_loop_thread()
|
|
95
|
+
raise
|
|
96
|
+
self._sync_active = True
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(
|
|
100
|
+
self,
|
|
101
|
+
exc_type: type[BaseException] | None,
|
|
102
|
+
exc: BaseException | None,
|
|
103
|
+
traceback: object | None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Close the Copilot session/client and stop the background loop."""
|
|
106
|
+
try:
|
|
107
|
+
if self._loop is not None:
|
|
108
|
+
self._run_on_loop(self._close_async(exc_type, exc, traceback))
|
|
109
|
+
finally:
|
|
110
|
+
self._sync_active = False
|
|
111
|
+
self._stop_loop_thread()
|
|
112
|
+
|
|
113
|
+
async def send_and_wait(
|
|
114
|
+
self,
|
|
115
|
+
prompt: str,
|
|
116
|
+
*,
|
|
117
|
+
timeout: float | None = None,
|
|
118
|
+
) -> str:
|
|
119
|
+
"""Send ``prompt`` through the shared session and return assistant text."""
|
|
120
|
+
if not self._active or self._session is None:
|
|
121
|
+
raise RuntimeError("CopilotAgentSession is not active")
|
|
122
|
+
if not isinstance(prompt, str):
|
|
123
|
+
raise TypeError("prompt must be a str")
|
|
124
|
+
if timeout is not None and timeout <= 0:
|
|
125
|
+
raise ValueError("timeout must be greater than 0")
|
|
126
|
+
|
|
127
|
+
effective_timeout = self.timeout if timeout is None else timeout
|
|
128
|
+
if self._send_lock is None:
|
|
129
|
+
self._send_lock = asyncio.Lock()
|
|
130
|
+
async with self._send_lock:
|
|
131
|
+
response = await self._session.send_and_wait(
|
|
132
|
+
prompt,
|
|
133
|
+
timeout=effective_timeout,
|
|
134
|
+
)
|
|
135
|
+
return self._response_text(response)
|
|
136
|
+
|
|
137
|
+
def on(self, handler: Callable[[Any], None]) -> Callable[[], None]:
|
|
138
|
+
"""Subscribe to Copilot session events.
|
|
139
|
+
|
|
140
|
+
The returned callable unsubscribes ``handler``. Handlers may be called
|
|
141
|
+
from the session's event-loop thread when using the synchronous context
|
|
142
|
+
manager.
|
|
143
|
+
"""
|
|
144
|
+
if not callable(handler):
|
|
145
|
+
raise TypeError("handler must be callable")
|
|
146
|
+
with self._event_lock:
|
|
147
|
+
self._event_handlers.append(handler)
|
|
148
|
+
|
|
149
|
+
def unsubscribe() -> None:
|
|
150
|
+
with self._event_lock:
|
|
151
|
+
if handler in self._event_handlers:
|
|
152
|
+
self._event_handlers.remove(handler)
|
|
153
|
+
|
|
154
|
+
return unsubscribe
|
|
155
|
+
|
|
156
|
+
def handler(self) -> TaskHandler:
|
|
157
|
+
"""Return a synchronous AGENT task handler backed by this session."""
|
|
158
|
+
|
|
159
|
+
def run(context: TaskContext) -> str:
|
|
160
|
+
context.raise_if_cancelled()
|
|
161
|
+
if not self._sync_active or self._loop is None:
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
"CopilotAgentSession.handler() requires an active sync context",
|
|
164
|
+
)
|
|
165
|
+
with self._handler_lock:
|
|
166
|
+
context.raise_if_cancelled()
|
|
167
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
168
|
+
self.send_and_wait(context.payload, timeout=context.timeout),
|
|
169
|
+
self._loop,
|
|
170
|
+
)
|
|
171
|
+
while True:
|
|
172
|
+
try:
|
|
173
|
+
result = future.result(timeout=0.05)
|
|
174
|
+
except FutureTimeoutError:
|
|
175
|
+
if context.cancelled:
|
|
176
|
+
future.cancel()
|
|
177
|
+
self._abort_active()
|
|
178
|
+
raise TaskCancelled(
|
|
179
|
+
f"Task {context.id!r} was cancelled",
|
|
180
|
+
) from None
|
|
181
|
+
continue
|
|
182
|
+
except FutureCancelledError as error:
|
|
183
|
+
raise TaskCancelled(
|
|
184
|
+
f"Task {context.id!r} was cancelled",
|
|
185
|
+
) from error
|
|
186
|
+
context.raise_if_cancelled()
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
return run
|
|
190
|
+
|
|
191
|
+
async def _open_async(self) -> None:
|
|
192
|
+
"""Create the SDK client/session pair."""
|
|
193
|
+
from copilot import CopilotClient
|
|
194
|
+
from copilot.session import PermissionHandler
|
|
195
|
+
|
|
196
|
+
session_options = dict(self._session_options)
|
|
197
|
+
session_options.setdefault(
|
|
198
|
+
"on_permission_request",
|
|
199
|
+
PermissionHandler.approve_all,
|
|
200
|
+
)
|
|
201
|
+
session_options["model"] = self.model
|
|
202
|
+
if self.reasoning_effort is not None:
|
|
203
|
+
session_options["reasoning_effort"] = self.reasoning_effort
|
|
204
|
+
if self.working_directory is not None:
|
|
205
|
+
session_options["working_directory"] = self.working_directory
|
|
206
|
+
session_options["on_event"] = self._dispatch_event
|
|
207
|
+
|
|
208
|
+
client = CopilotClient()
|
|
209
|
+
entered_client = await client.__aenter__()
|
|
210
|
+
try:
|
|
211
|
+
session = await entered_client.create_session(**session_options)
|
|
212
|
+
entered_session = await session.__aenter__()
|
|
213
|
+
except BaseException:
|
|
214
|
+
await entered_client.__aexit__(None, None, None)
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
self._client = entered_client
|
|
218
|
+
self._session = entered_session
|
|
219
|
+
self._send_lock = asyncio.Lock()
|
|
220
|
+
self._active = True
|
|
221
|
+
|
|
222
|
+
async def _close_async(
|
|
223
|
+
self,
|
|
224
|
+
exc_type: type[BaseException] | None = None,
|
|
225
|
+
exc: BaseException | None = None,
|
|
226
|
+
traceback: object | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Close active SDK resources in reverse creation order."""
|
|
229
|
+
session = self._session
|
|
230
|
+
client = self._client
|
|
231
|
+
self._session = None
|
|
232
|
+
self._client = None
|
|
233
|
+
self._send_lock = None
|
|
234
|
+
self._active = False
|
|
235
|
+
|
|
236
|
+
close_error: BaseException | None = None
|
|
237
|
+
if session is not None:
|
|
238
|
+
try:
|
|
239
|
+
await session.__aexit__(exc_type, exc, traceback)
|
|
240
|
+
except BaseException as error:
|
|
241
|
+
close_error = error
|
|
242
|
+
if client is not None:
|
|
243
|
+
try:
|
|
244
|
+
await client.__aexit__(exc_type, exc, traceback)
|
|
245
|
+
except BaseException as error:
|
|
246
|
+
if close_error is None:
|
|
247
|
+
close_error = error
|
|
248
|
+
if close_error is not None:
|
|
249
|
+
raise close_error
|
|
250
|
+
|
|
251
|
+
async def _abort_active_async(self) -> None:
|
|
252
|
+
"""Abort the active Copilot turn if the SDK session supports it."""
|
|
253
|
+
session = self._session
|
|
254
|
+
abort = getattr(session, "abort", None)
|
|
255
|
+
if callable(abort):
|
|
256
|
+
await abort()
|
|
257
|
+
|
|
258
|
+
def _abort_active(self) -> None:
|
|
259
|
+
"""Best-effort abort for a cancelled synchronous handler call."""
|
|
260
|
+
if self._loop is None:
|
|
261
|
+
return
|
|
262
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
263
|
+
self._abort_active_async(),
|
|
264
|
+
self._loop,
|
|
265
|
+
)
|
|
266
|
+
future.result(timeout=5)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _response_text(response: Any) -> str:
|
|
270
|
+
"""Normalize a Copilot SDK response event to assistant text."""
|
|
271
|
+
from copilot.generated.session_events import AssistantMessageData
|
|
272
|
+
|
|
273
|
+
if response is None or not isinstance(response.data, AssistantMessageData):
|
|
274
|
+
return ""
|
|
275
|
+
return response.data.content or ""
|
|
276
|
+
|
|
277
|
+
def _dispatch_event(self, event: Any) -> None:
|
|
278
|
+
"""Fan out one SDK event to registered session subscribers."""
|
|
279
|
+
with self._event_lock:
|
|
280
|
+
handlers = list(self._event_handlers)
|
|
281
|
+
for handler in handlers:
|
|
282
|
+
try:
|
|
283
|
+
handler(event)
|
|
284
|
+
except BaseException as error:
|
|
285
|
+
self.event_errors.append(error)
|
|
286
|
+
|
|
287
|
+
def _start_loop_thread(self) -> None:
|
|
288
|
+
"""Start the background event loop used by the sync API."""
|
|
289
|
+
self._thread_ready.clear()
|
|
290
|
+
|
|
291
|
+
def run_loop() -> None:
|
|
292
|
+
loop = asyncio.new_event_loop()
|
|
293
|
+
asyncio.set_event_loop(loop)
|
|
294
|
+
self._loop = loop
|
|
295
|
+
self._thread_ready.set()
|
|
296
|
+
try:
|
|
297
|
+
loop.run_forever()
|
|
298
|
+
finally:
|
|
299
|
+
loop.close()
|
|
300
|
+
self._loop = None
|
|
301
|
+
|
|
302
|
+
self._thread = Thread(
|
|
303
|
+
target=run_loop,
|
|
304
|
+
name="ttasks-copilot-agent-session",
|
|
305
|
+
daemon=True,
|
|
306
|
+
)
|
|
307
|
+
self._thread.start()
|
|
308
|
+
self._thread_ready.wait()
|
|
309
|
+
|
|
310
|
+
def _stop_loop_thread(self) -> None:
|
|
311
|
+
"""Stop and join the background event-loop thread."""
|
|
312
|
+
loop = self._loop
|
|
313
|
+
thread = self._thread
|
|
314
|
+
if loop is not None:
|
|
315
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
316
|
+
if thread is not None:
|
|
317
|
+
thread.join(timeout=5)
|
|
318
|
+
self._thread = None
|
|
319
|
+
self._thread_ready.clear()
|
|
320
|
+
|
|
321
|
+
def _run_on_loop(self, coroutine: Any) -> Any:
|
|
322
|
+
"""Run ``coroutine`` on the sync background loop and return its result."""
|
|
323
|
+
if self._loop is None:
|
|
324
|
+
raise RuntimeError("CopilotAgentSession event loop is not running")
|
|
325
|
+
future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
|
|
326
|
+
return future.result()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
__all__ = ["CopilotAgentSession"]
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""Tests for shared Copilot agent sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from concurrent.futures import CancelledError, ThreadPoolExecutor
|
|
9
|
+
from types import ModuleType, SimpleNamespace
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from ttasks import (
|
|
15
|
+
CopilotAgentSession,
|
|
16
|
+
Task,
|
|
17
|
+
TaskCancelled,
|
|
18
|
+
TaskExecutor,
|
|
19
|
+
TaskStatus,
|
|
20
|
+
TaskType,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def install_fake_copilot(
|
|
25
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
26
|
+
*,
|
|
27
|
+
content: str | None = "response",
|
|
28
|
+
data: object | None = None,
|
|
29
|
+
delay: float = 0,
|
|
30
|
+
enter_error: BaseException | None = None,
|
|
31
|
+
session_enter_error: BaseException | None = None,
|
|
32
|
+
session_exit_error: BaseException | None = None,
|
|
33
|
+
client_exit_error: BaseException | None = None,
|
|
34
|
+
no_abort: bool = False,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Install fake Copilot SDK modules and return recorded calls."""
|
|
37
|
+
recorded: dict[str, Any] = {
|
|
38
|
+
"clients": [],
|
|
39
|
+
"sessions": [],
|
|
40
|
+
"prompts": [],
|
|
41
|
+
"timeouts": [],
|
|
42
|
+
"create_sessions": [],
|
|
43
|
+
"events": [],
|
|
44
|
+
"active_sends": 0,
|
|
45
|
+
"max_active_sends": 0,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class AssistantMessageData:
|
|
49
|
+
"""Fake assistant message data."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, content: str | None) -> None:
|
|
52
|
+
self.content = content
|
|
53
|
+
|
|
54
|
+
class FakeSession:
|
|
55
|
+
"""Fake Copilot session."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, on_event: Any | None) -> None:
|
|
58
|
+
self.on_event = on_event
|
|
59
|
+
self.exited = False
|
|
60
|
+
self.aborted = False
|
|
61
|
+
recorded["sessions"].append(self)
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self) -> FakeSession:
|
|
64
|
+
if session_enter_error is not None:
|
|
65
|
+
raise session_enter_error
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def __aexit__(self, *args: object) -> None:
|
|
69
|
+
self.exited = True
|
|
70
|
+
recorded["session_exited"] = True
|
|
71
|
+
if session_exit_error is not None:
|
|
72
|
+
raise session_exit_error
|
|
73
|
+
|
|
74
|
+
async def send_and_wait(
|
|
75
|
+
self,
|
|
76
|
+
prompt: str,
|
|
77
|
+
*,
|
|
78
|
+
timeout: float | None,
|
|
79
|
+
) -> object:
|
|
80
|
+
recorded["prompts"].append(prompt)
|
|
81
|
+
recorded["timeouts"].append(timeout)
|
|
82
|
+
recorded["active_sends"] += 1
|
|
83
|
+
recorded["max_active_sends"] = max(
|
|
84
|
+
recorded["max_active_sends"],
|
|
85
|
+
recorded["active_sends"],
|
|
86
|
+
)
|
|
87
|
+
if self.on_event is not None:
|
|
88
|
+
event = SimpleNamespace(data=AssistantMessageData(f"event:{prompt}"))
|
|
89
|
+
recorded["events"].append(event)
|
|
90
|
+
self.on_event(event)
|
|
91
|
+
try:
|
|
92
|
+
if delay:
|
|
93
|
+
await asyncio.sleep(delay)
|
|
94
|
+
if data is not None:
|
|
95
|
+
return SimpleNamespace(data=data)
|
|
96
|
+
return SimpleNamespace(data=AssistantMessageData(content))
|
|
97
|
+
finally:
|
|
98
|
+
recorded["active_sends"] -= 1
|
|
99
|
+
|
|
100
|
+
if not no_abort:
|
|
101
|
+
|
|
102
|
+
async def abort(self) -> None:
|
|
103
|
+
self.aborted = True
|
|
104
|
+
recorded["aborted"] = True
|
|
105
|
+
|
|
106
|
+
class FakeClient:
|
|
107
|
+
"""Fake Copilot client."""
|
|
108
|
+
|
|
109
|
+
def __init__(self) -> None:
|
|
110
|
+
self.exited = False
|
|
111
|
+
recorded["clients"].append(self)
|
|
112
|
+
|
|
113
|
+
async def __aenter__(self) -> FakeClient:
|
|
114
|
+
if enter_error is not None:
|
|
115
|
+
raise enter_error
|
|
116
|
+
recorded["client_entered"] = True
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
async def __aexit__(self, *args: object) -> None:
|
|
120
|
+
self.exited = True
|
|
121
|
+
recorded["client_exited"] = True
|
|
122
|
+
if client_exit_error is not None:
|
|
123
|
+
raise client_exit_error
|
|
124
|
+
|
|
125
|
+
async def create_session(self, **kwargs: object) -> FakeSession:
|
|
126
|
+
recorded["create_sessions"].append(kwargs)
|
|
127
|
+
return FakeSession(kwargs.get("on_event"))
|
|
128
|
+
|
|
129
|
+
class FakePermissionHandler:
|
|
130
|
+
"""Fake Copilot permission handler namespace."""
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def approve_all(*args: object) -> object:
|
|
134
|
+
return object()
|
|
135
|
+
|
|
136
|
+
copilot: Any = ModuleType("copilot")
|
|
137
|
+
copilot.__path__ = []
|
|
138
|
+
copilot.CopilotClient = FakeClient
|
|
139
|
+
generated: Any = ModuleType("copilot.generated")
|
|
140
|
+
generated.__path__ = []
|
|
141
|
+
session_events: Any = ModuleType("copilot.generated.session_events")
|
|
142
|
+
session_events.AssistantMessageData = AssistantMessageData
|
|
143
|
+
session_module: Any = ModuleType("copilot.session")
|
|
144
|
+
session_module.PermissionHandler = FakePermissionHandler
|
|
145
|
+
|
|
146
|
+
monkeypatch.setitem(sys.modules, "copilot", copilot)
|
|
147
|
+
monkeypatch.setitem(sys.modules, "copilot.generated", generated)
|
|
148
|
+
monkeypatch.setitem(sys.modules, "copilot.generated.session_events", session_events)
|
|
149
|
+
monkeypatch.setitem(sys.modules, "copilot.session", session_module)
|
|
150
|
+
return recorded
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_shared_copilot_agent_session_reuses_one_sdk_session(
|
|
154
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Multiple AGENT tasks share one Copilot client/session."""
|
|
157
|
+
recorded = install_fake_copilot(monkeypatch, content="done")
|
|
158
|
+
with CopilotAgentSession() as session:
|
|
159
|
+
executor = TaskExecutor.empty()
|
|
160
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
161
|
+
|
|
162
|
+
first = executor.execute(Task.agent("first", title="First"))
|
|
163
|
+
second = executor.execute(Task.agent("second", title="Second"))
|
|
164
|
+
|
|
165
|
+
assert first.output == "done"
|
|
166
|
+
assert second.output == "done"
|
|
167
|
+
assert recorded["prompts"] == ["first", "second"]
|
|
168
|
+
assert len(recorded["clients"]) == 1
|
|
169
|
+
assert len(recorded["sessions"]) == 1
|
|
170
|
+
assert recorded["client_exited"] is True
|
|
171
|
+
assert recorded["session_exited"] is True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_shared_copilot_agent_session_rejects_invalid_configuration() -> None:
|
|
175
|
+
"""Session construction rejects invalid model and timeout values."""
|
|
176
|
+
with pytest.raises(ValueError, match="model must not be empty"):
|
|
177
|
+
CopilotAgentSession(model="")
|
|
178
|
+
with pytest.raises(ValueError, match="timeout must be greater than 0"):
|
|
179
|
+
CopilotAgentSession(timeout=0)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_shared_copilot_agent_session_passes_session_options(
|
|
183
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Session configuration is forwarded to CopilotClient.create_session."""
|
|
186
|
+
recorded = install_fake_copilot(monkeypatch)
|
|
187
|
+
|
|
188
|
+
with CopilotAgentSession(
|
|
189
|
+
model="agent-custom",
|
|
190
|
+
reasoning_effort="medium",
|
|
191
|
+
working_directory="/tmp/repo",
|
|
192
|
+
streaming=True,
|
|
193
|
+
excluded_tools=["bash"],
|
|
194
|
+
):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
create_session = recorded["create_sessions"][0]
|
|
198
|
+
assert create_session["model"] == "agent-custom"
|
|
199
|
+
assert create_session["reasoning_effort"] == "medium"
|
|
200
|
+
assert create_session["working_directory"] == "/tmp/repo"
|
|
201
|
+
assert create_session["streaming"] is True
|
|
202
|
+
assert create_session["excluded_tools"] == ["bash"]
|
|
203
|
+
assert callable(create_session["on_permission_request"])
|
|
204
|
+
assert callable(create_session["on_event"])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_shared_copilot_agent_session_handler_uses_task_timeout(
|
|
208
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Task timeout overrides the session default timeout for handler calls."""
|
|
211
|
+
recorded = install_fake_copilot(monkeypatch)
|
|
212
|
+
with CopilotAgentSession(timeout=30) as session:
|
|
213
|
+
executor = TaskExecutor.empty()
|
|
214
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
215
|
+
|
|
216
|
+
executor.execute(Task.agent("default timeout", title="Default"))
|
|
217
|
+
executor.execute(Task.agent("task timeout", title="Task", timeout=2.5))
|
|
218
|
+
|
|
219
|
+
assert recorded["timeouts"] == [30, 2.5]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_shared_copilot_agent_session_handler_uses_no_default_timeout(
|
|
223
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""When neither session nor task timeout is set, timeout is forwarded as None."""
|
|
226
|
+
recorded = install_fake_copilot(monkeypatch)
|
|
227
|
+
with CopilotAgentSession() as session:
|
|
228
|
+
executor = TaskExecutor.empty()
|
|
229
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
230
|
+
|
|
231
|
+
executor.execute(Task.agent("no timeout", title="No timeout"))
|
|
232
|
+
|
|
233
|
+
assert recorded["timeouts"] == [None]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_shared_copilot_agent_session_on_subscribes_to_events(
|
|
237
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Constructor and explicit event subscribers receive session events."""
|
|
240
|
+
install_fake_copilot(monkeypatch)
|
|
241
|
+
seen: list[str] = []
|
|
242
|
+
explicit: list[str] = []
|
|
243
|
+
|
|
244
|
+
def on_event(event: Any) -> None:
|
|
245
|
+
seen.append(event.data.content)
|
|
246
|
+
|
|
247
|
+
with CopilotAgentSession(on_event=on_event) as session:
|
|
248
|
+
unsubscribe = session.on(lambda event: explicit.append(event.data.content))
|
|
249
|
+
executor = TaskExecutor.empty()
|
|
250
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
251
|
+
executor.execute(Task.agent("hello", title="Hello"))
|
|
252
|
+
unsubscribe()
|
|
253
|
+
executor.execute(Task.agent("again", title="Again"))
|
|
254
|
+
|
|
255
|
+
assert seen == ["event:hello", "event:again"]
|
|
256
|
+
assert explicit == ["event:hello"]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_shared_copilot_agent_session_on_rejects_non_callable() -> None:
|
|
260
|
+
"""Event subscription requires a callable handler."""
|
|
261
|
+
with pytest.raises(TypeError, match="handler must be callable"):
|
|
262
|
+
CopilotAgentSession().on("not callable") # type: ignore[arg-type]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_shared_copilot_agent_session_event_errors_are_isolated(
|
|
266
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""One broken event subscriber does not prevent later subscribers."""
|
|
269
|
+
install_fake_copilot(monkeypatch)
|
|
270
|
+
seen: list[str] = []
|
|
271
|
+
|
|
272
|
+
def broken(_event: Any) -> None:
|
|
273
|
+
raise RuntimeError("observer failed")
|
|
274
|
+
|
|
275
|
+
with CopilotAgentSession() as session:
|
|
276
|
+
session.on(broken)
|
|
277
|
+
session.on(lambda event: seen.append(event.data.content))
|
|
278
|
+
executor = TaskExecutor.empty()
|
|
279
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
280
|
+
executor.execute(Task.agent("hello", title="Hello"))
|
|
281
|
+
|
|
282
|
+
assert seen == ["event:hello"]
|
|
283
|
+
assert len(session.event_errors) == 1
|
|
284
|
+
assert str(session.event_errors[0]) == "observer failed"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_shared_copilot_agent_session_rejects_handler_outside_sync_context(
|
|
288
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""The synchronous handler fails clearly outside a sync session lifecycle."""
|
|
291
|
+
install_fake_copilot(monkeypatch)
|
|
292
|
+
session = CopilotAgentSession()
|
|
293
|
+
executor = TaskExecutor.empty()
|
|
294
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
295
|
+
|
|
296
|
+
with pytest.raises(RuntimeError, match="requires an active sync context"):
|
|
297
|
+
executor.execute(Task.agent("hello", title="Hello"))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_shared_copilot_agent_session_rejects_double_enter(
|
|
301
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""An active session cannot be entered again."""
|
|
304
|
+
install_fake_copilot(monkeypatch)
|
|
305
|
+
session = CopilotAgentSession()
|
|
306
|
+
session.__enter__()
|
|
307
|
+
try:
|
|
308
|
+
with pytest.raises(RuntimeError, match="already active"):
|
|
309
|
+
session.__enter__()
|
|
310
|
+
finally:
|
|
311
|
+
session.__exit__(None, None, None)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_shared_copilot_agent_session_rejects_double_async_enter(
|
|
315
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
316
|
+
) -> None:
|
|
317
|
+
"""An active async session cannot be entered again."""
|
|
318
|
+
install_fake_copilot(monkeypatch)
|
|
319
|
+
|
|
320
|
+
async def run() -> None:
|
|
321
|
+
session = CopilotAgentSession()
|
|
322
|
+
await session.__aenter__()
|
|
323
|
+
try:
|
|
324
|
+
with pytest.raises(RuntimeError, match="already active"):
|
|
325
|
+
await session.__aenter__()
|
|
326
|
+
finally:
|
|
327
|
+
await session.__aexit__(None, None, None)
|
|
328
|
+
|
|
329
|
+
asyncio.run(run())
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_shared_copilot_agent_session_serializes_concurrent_handler_calls(
|
|
333
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""One shared session processes concurrent handler calls one at a time."""
|
|
336
|
+
recorded = install_fake_copilot(monkeypatch, delay=0.05)
|
|
337
|
+
with CopilotAgentSession() as session:
|
|
338
|
+
executor = TaskExecutor.empty()
|
|
339
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
340
|
+
tasks = [
|
|
341
|
+
Task.agent("one", title="One"),
|
|
342
|
+
Task.agent("two", title="Two"),
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
346
|
+
results = list(pool.map(executor.execute, tasks))
|
|
347
|
+
|
|
348
|
+
assert [result.output for result in results] == ["response", "response"]
|
|
349
|
+
assert recorded["max_active_sends"] == 1
|
|
350
|
+
assert sorted(recorded["prompts"]) == ["one", "two"]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_shared_copilot_agent_session_cancels_in_flight_handler(
|
|
354
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Cancelling a running task aborts the active Copilot session turn."""
|
|
357
|
+
recorded = install_fake_copilot(monkeypatch, delay=1)
|
|
358
|
+
with CopilotAgentSession() as session:
|
|
359
|
+
executor = TaskExecutor.empty()
|
|
360
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
361
|
+
task = Task.agent("slow", title="Slow")
|
|
362
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
363
|
+
future = pool.submit(executor.execute, task)
|
|
364
|
+
while recorded["active_sends"] == 0:
|
|
365
|
+
time.sleep(0.01)
|
|
366
|
+
executor.cancel(task)
|
|
367
|
+
|
|
368
|
+
with pytest.raises(TaskCancelled):
|
|
369
|
+
future.result(timeout=2)
|
|
370
|
+
|
|
371
|
+
assert recorded["aborted"] is True
|
|
372
|
+
assert task.status is TaskStatus.CANCELLED
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_shared_copilot_agent_session_cancels_without_abort_method(
|
|
376
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Cancellation still succeeds if the installed SDK lacks session.abort."""
|
|
379
|
+
recorded = install_fake_copilot(monkeypatch, delay=1, no_abort=True)
|
|
380
|
+
with CopilotAgentSession() as session:
|
|
381
|
+
executor = TaskExecutor.empty()
|
|
382
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
383
|
+
task = Task.agent("slow", title="Slow")
|
|
384
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
385
|
+
future = pool.submit(executor.execute, task)
|
|
386
|
+
while recorded["active_sends"] == 0:
|
|
387
|
+
time.sleep(0.01)
|
|
388
|
+
executor.cancel(task)
|
|
389
|
+
|
|
390
|
+
with pytest.raises(TaskCancelled):
|
|
391
|
+
future.result(timeout=2)
|
|
392
|
+
|
|
393
|
+
assert "aborted" not in recorded
|
|
394
|
+
assert task.status is TaskStatus.CANCELLED
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_shared_copilot_agent_session_wraps_cancelled_future(
|
|
398
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
399
|
+
) -> None:
|
|
400
|
+
"""A cancelled sync bridge future is surfaced as TaskCancelled."""
|
|
401
|
+
install_fake_copilot(monkeypatch)
|
|
402
|
+
|
|
403
|
+
class FakeFuture:
|
|
404
|
+
def result(self, *, timeout: float) -> str:
|
|
405
|
+
raise CancelledError
|
|
406
|
+
|
|
407
|
+
def fake_run_coroutine_threadsafe(
|
|
408
|
+
coroutine: Any,
|
|
409
|
+
_loop: asyncio.AbstractEventLoop,
|
|
410
|
+
) -> FakeFuture:
|
|
411
|
+
coroutine.close()
|
|
412
|
+
return FakeFuture()
|
|
413
|
+
|
|
414
|
+
original_run_coroutine_threadsafe = asyncio.run_coroutine_threadsafe
|
|
415
|
+
with CopilotAgentSession() as session:
|
|
416
|
+
monkeypatch.setattr(
|
|
417
|
+
asyncio,
|
|
418
|
+
"run_coroutine_threadsafe",
|
|
419
|
+
fake_run_coroutine_threadsafe,
|
|
420
|
+
)
|
|
421
|
+
executor = TaskExecutor.empty()
|
|
422
|
+
executor.register(TaskType.AGENT, session.handler())
|
|
423
|
+
with pytest.raises(TaskCancelled, match="was cancelled"):
|
|
424
|
+
executor.execute(Task.agent("cancelled", title="Cancelled"))
|
|
425
|
+
monkeypatch.setattr(
|
|
426
|
+
asyncio,
|
|
427
|
+
"run_coroutine_threadsafe",
|
|
428
|
+
original_run_coroutine_threadsafe,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def test_shared_copilot_agent_session_abort_without_loop_returns() -> None:
|
|
433
|
+
"""Best-effort abort is a no-op before the sync loop is started."""
|
|
434
|
+
CopilotAgentSession()._abort_active()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_shared_copilot_agent_session_enter_failure_cleans_up_thread(
|
|
438
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Sync enter failures do not leave the background loop thread running."""
|
|
441
|
+
install_fake_copilot(monkeypatch, enter_error=RuntimeError("connect failed"))
|
|
442
|
+
session = CopilotAgentSession()
|
|
443
|
+
|
|
444
|
+
with pytest.raises(RuntimeError, match="connect failed"), session:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
thread = session._thread
|
|
448
|
+
assert thread is None or not thread.is_alive()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_shared_copilot_agent_session_session_enter_failure_closes_client(
|
|
452
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
453
|
+
) -> None:
|
|
454
|
+
"""Session enter failures close the already-entered client."""
|
|
455
|
+
recorded = install_fake_copilot(
|
|
456
|
+
monkeypatch,
|
|
457
|
+
session_enter_error=RuntimeError("session failed"),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
with pytest.raises(RuntimeError, match="session failed"), CopilotAgentSession():
|
|
461
|
+
pass
|
|
462
|
+
|
|
463
|
+
assert recorded["client_exited"] is True
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_shared_copilot_agent_session_close_raises_session_error(
|
|
467
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Session close errors are propagated after close is attempted."""
|
|
470
|
+
install_fake_copilot(monkeypatch, session_exit_error=RuntimeError("session close"))
|
|
471
|
+
|
|
472
|
+
with pytest.raises(RuntimeError, match="session close"), CopilotAgentSession():
|
|
473
|
+
pass
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_shared_copilot_agent_session_close_raises_client_error(
|
|
477
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
478
|
+
) -> None:
|
|
479
|
+
"""Client close errors are propagated when session close succeeds."""
|
|
480
|
+
install_fake_copilot(monkeypatch, client_exit_error=RuntimeError("client close"))
|
|
481
|
+
|
|
482
|
+
with pytest.raises(RuntimeError, match="client close"), CopilotAgentSession():
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_shared_copilot_agent_session_async_context(
|
|
487
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""The async context manager can send directly through the shared session."""
|
|
490
|
+
recorded = install_fake_copilot(monkeypatch, content="async done")
|
|
491
|
+
|
|
492
|
+
async def run() -> str:
|
|
493
|
+
async with CopilotAgentSession(timeout=7) as session:
|
|
494
|
+
return await session.send_and_wait("hello")
|
|
495
|
+
|
|
496
|
+
output = asyncio.run(run())
|
|
497
|
+
|
|
498
|
+
assert output == "async done"
|
|
499
|
+
assert recorded["prompts"] == ["hello"]
|
|
500
|
+
assert recorded["timeouts"] == [7]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_shared_copilot_agent_session_async_validation_and_empty_response(
|
|
504
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
505
|
+
) -> None:
|
|
506
|
+
"""The async API validates input and normalizes non-assistant responses."""
|
|
507
|
+
install_fake_copilot(monkeypatch, content=None)
|
|
508
|
+
|
|
509
|
+
async def run() -> None:
|
|
510
|
+
session = CopilotAgentSession()
|
|
511
|
+
with pytest.raises(RuntimeError, match="not active"):
|
|
512
|
+
await session.send_and_wait("inactive")
|
|
513
|
+
async with session:
|
|
514
|
+
with pytest.raises(TypeError, match="prompt must be a str"):
|
|
515
|
+
await session.send_and_wait(123) # type: ignore[arg-type]
|
|
516
|
+
with pytest.raises(ValueError, match="timeout must be greater than 0"):
|
|
517
|
+
await session.send_and_wait("bad timeout", timeout=0)
|
|
518
|
+
session._send_lock = None
|
|
519
|
+
assert await session.send_and_wait("empty") == ""
|
|
520
|
+
|
|
521
|
+
asyncio.run(run())
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_shared_copilot_agent_session_unknown_response_data_returns_empty(
|
|
525
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
526
|
+
) -> None:
|
|
527
|
+
"""Unexpected Copilot response data normalizes to an empty string."""
|
|
528
|
+
install_fake_copilot(monkeypatch, data=object())
|
|
529
|
+
|
|
530
|
+
async def run() -> str:
|
|
531
|
+
async with CopilotAgentSession() as session:
|
|
532
|
+
return await session.send_and_wait("unknown")
|
|
533
|
+
|
|
534
|
+
assert asyncio.run(run()) == ""
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_shared_copilot_agent_session_run_on_loop_requires_loop() -> None:
|
|
538
|
+
"""The sync loop bridge fails clearly if no background loop exists."""
|
|
539
|
+
session = CopilotAgentSession()
|
|
540
|
+
|
|
541
|
+
async def noop() -> None:
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
coroutine = noop()
|
|
545
|
+
try:
|
|
546
|
+
with pytest.raises(RuntimeError, match="event loop is not running"):
|
|
547
|
+
session._run_on_loop(coroutine)
|
|
548
|
+
finally:
|
|
549
|
+
coroutine.close()
|
|
@@ -20,6 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
import pytest
|
|
21
21
|
|
|
22
22
|
from ttasks import (
|
|
23
|
+
CopilotAgentSession,
|
|
23
24
|
RetryPolicy,
|
|
24
25
|
SQLiteStore,
|
|
25
26
|
Task,
|
|
@@ -549,3 +550,54 @@ def test_mixed_type_copilot_workflow(tmp_path: Path) -> None:
|
|
|
549
550
|
if task.result is not None:
|
|
550
551
|
assert persisted.result is not None
|
|
551
552
|
assert persisted.result.output == task.result.output
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@pytest.mark.skipif(
|
|
556
|
+
not _copilot_available(),
|
|
557
|
+
reason="Copilot SDK CLI not on PATH; skipping live shared-session test",
|
|
558
|
+
)
|
|
559
|
+
def test_shared_copilot_agent_session_graph_multiturn(tmp_path: Path) -> None:
|
|
560
|
+
"""Two AGENT graph nodes share one Copilot session conversation."""
|
|
561
|
+
memory_file = tmp_path / "memory.txt"
|
|
562
|
+
code_word = "blue-sparrow-ttasks-live"
|
|
563
|
+
|
|
564
|
+
remember = Task.agent(
|
|
565
|
+
(
|
|
566
|
+
"Remember this code word for the next task in this same session: "
|
|
567
|
+
f"{code_word}. Do not create or modify any files. "
|
|
568
|
+
f"Reply exactly: remembered {code_word}"
|
|
569
|
+
),
|
|
570
|
+
title="agent_remember",
|
|
571
|
+
timeout=180,
|
|
572
|
+
)
|
|
573
|
+
write_memory = Task.agent(
|
|
574
|
+
(
|
|
575
|
+
"Using only the code word I gave you earlier in this session, "
|
|
576
|
+
"create a file named memory.txt in the working directory containing "
|
|
577
|
+
"only that code word followed by a newline."
|
|
578
|
+
),
|
|
579
|
+
title="agent_write_memory",
|
|
580
|
+
timeout=180,
|
|
581
|
+
)
|
|
582
|
+
verify_memory = Task.bash(
|
|
583
|
+
f"test \"$(cat {memory_file})\" = {code_word!r}",
|
|
584
|
+
title="verify_memory",
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
graph = TaskGraph(title="shared-copilot-session")
|
|
588
|
+
graph.add(remember)
|
|
589
|
+
graph.add(write_memory, after=[remember])
|
|
590
|
+
graph.add(verify_memory, after=[write_memory])
|
|
591
|
+
|
|
592
|
+
with CopilotAgentSession(working_directory=str(tmp_path)) as agent:
|
|
593
|
+
executor = TaskExecutor()
|
|
594
|
+
executor.register(TaskType.AGENT, agent.handler())
|
|
595
|
+
graph.run(executor)
|
|
596
|
+
|
|
597
|
+
assert graph.ok, (
|
|
598
|
+
f"graph not ok: statuses="
|
|
599
|
+
f"{ {task.title: task.status.value for task in graph} }"
|
|
600
|
+
)
|
|
601
|
+
for task in graph:
|
|
602
|
+
assert task.status == TaskStatus.SUCCEEDED
|
|
603
|
+
assert memory_file.read_text() == f"{code_word}\n"
|
|
@@ -517,6 +517,22 @@ def test_execute_retry_policy_applies_backoff_between_attempts(
|
|
|
517
517
|
assert sleeps == [0.25]
|
|
518
518
|
|
|
519
519
|
|
|
520
|
+
def test_retry_backoff_long_sleep_returns_at_deadline(
|
|
521
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""Long retry backoff sleeps in small intervals until the deadline."""
|
|
524
|
+
task = Task.bash("", title="Example")
|
|
525
|
+
ticks = iter([0.0, 0.1, 0.6])
|
|
526
|
+
sleeps: list[float] = []
|
|
527
|
+
|
|
528
|
+
monkeypatch.setattr(time, "monotonic", lambda: next(ticks))
|
|
529
|
+
monkeypatch.setattr(time, "sleep", sleeps.append)
|
|
530
|
+
|
|
531
|
+
TaskExecutor._sleep_retry_backoff(task, 0.51)
|
|
532
|
+
|
|
533
|
+
assert sleeps == [0.05]
|
|
534
|
+
|
|
535
|
+
|
|
520
536
|
def test_execute_retry_policy_honors_cancellation_during_backoff(
|
|
521
537
|
monkeypatch: pytest.MonkeyPatch,
|
|
522
538
|
) -> None:
|
|
@@ -15,6 +15,7 @@ from ttasks import _sqlite as sqlite_mod
|
|
|
15
15
|
from ttasks import _store as store_mod
|
|
16
16
|
from ttasks import _task as task_mod
|
|
17
17
|
from ttasks import _version as version_mod
|
|
18
|
+
from ttasks import copilot as copilot_mod
|
|
18
19
|
|
|
19
20
|
EXPECTED_PUBLIC_NAMES = {
|
|
20
21
|
"EventBus",
|
|
@@ -22,6 +23,7 @@ EXPECTED_PUBLIC_NAMES = {
|
|
|
22
23
|
"RetryPolicy",
|
|
23
24
|
"SQLiteStore",
|
|
24
25
|
"Store",
|
|
26
|
+
"CopilotAgentSession",
|
|
25
27
|
"Task",
|
|
26
28
|
"TaskCancelled",
|
|
27
29
|
"TaskContext",
|
|
@@ -56,6 +58,7 @@ def test_top_level_names_are_the_same_objects_as_submodule_names() -> None:
|
|
|
56
58
|
assert ttasks.InMemoryStore is store_mod.InMemoryStore
|
|
57
59
|
assert ttasks.SQLiteStore is sqlite_mod.SQLiteStore
|
|
58
60
|
assert ttasks.Store is store_mod.Store
|
|
61
|
+
assert ttasks.CopilotAgentSession is copilot_mod.CopilotAgentSession
|
|
59
62
|
assert ttasks.Task is task_mod.Task
|
|
60
63
|
assert ttasks.TaskStatus is task_mod.TaskStatus
|
|
61
64
|
assert ttasks.TaskType is task_mod.TaskType
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|