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.
Files changed (45) hide show
  1. {ttasks-0.3.1 → ttasks-0.4.0}/PKG-INFO +1 -1
  2. {ttasks-0.3.1 → ttasks-0.4.0}/docs/quickstart.md +20 -0
  3. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/__init__.py +2 -0
  4. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_version.py +2 -2
  5. ttasks-0.4.0/src/ttasks/copilot.py +329 -0
  6. ttasks-0.4.0/tests/test_copilot_session.py +549 -0
  7. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_e2e.py +52 -0
  8. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_executor.py +16 -0
  9. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_public_api.py +3 -0
  10. {ttasks-0.3.1 → ttasks-0.4.0}/.github/workflows/docs.yml +0 -0
  11. {ttasks-0.3.1 → ttasks-0.4.0}/.github/workflows/publish.yml +0 -0
  12. {ttasks-0.3.1 → ttasks-0.4.0}/.gitignore +0 -0
  13. {ttasks-0.3.1 → ttasks-0.4.0}/.python-version +0 -0
  14. {ttasks-0.3.1 → ttasks-0.4.0}/.vscode/launch.json +0 -0
  15. {ttasks-0.3.1 → ttasks-0.4.0}/.vscode/settings.json +0 -0
  16. {ttasks-0.3.1 → ttasks-0.4.0}/README.md +0 -0
  17. {ttasks-0.3.1 → ttasks-0.4.0}/docs/index.md +0 -0
  18. {ttasks-0.3.1 → ttasks-0.4.0}/docs/patterns/finally-tasks.md +0 -0
  19. {ttasks-0.3.1 → ttasks-0.4.0}/docs/patterns/progress-and-output.md +0 -0
  20. {ttasks-0.3.1 → ttasks-0.4.0}/docs/patterns/retries-and-cancellation.md +0 -0
  21. {ttasks-0.3.1 → ttasks-0.4.0}/docs/reference/api.md +0 -0
  22. {ttasks-0.3.1 → ttasks-0.4.0}/docs/tutorials/async-execution.md +0 -0
  23. {ttasks-0.3.1 → ttasks-0.4.0}/docs/tutorials/graph-workflows.md +0 -0
  24. {ttasks-0.3.1 → ttasks-0.4.0}/docs/tutorials/task-execution.md +0 -0
  25. {ttasks-0.3.1 → ttasks-0.4.0}/examples/finally_tasks.py +0 -0
  26. {ttasks-0.3.1 → ttasks-0.4.0}/main.py +0 -0
  27. {ttasks-0.3.1 → ttasks-0.4.0}/mkdocs.yml +0 -0
  28. {ttasks-0.3.1 → ttasks-0.4.0}/pyproject.toml +0 -0
  29. {ttasks-0.3.1 → ttasks-0.4.0}/scripts/preflight.py +0 -0
  30. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_events.py +0 -0
  31. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_exceptions.py +0 -0
  32. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_executor.py +0 -0
  33. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_graph.py +0 -0
  34. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_sqlite.py +0 -0
  35. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_store.py +0 -0
  36. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/_task.py +0 -0
  37. {ttasks-0.3.1 → ttasks-0.4.0}/src/ttasks/py.typed +0 -0
  38. {ttasks-0.3.1 → ttasks-0.4.0}/tests/conftest.py +0 -0
  39. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_events.py +0 -0
  40. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_sqlite_store.py +0 -0
  41. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_store.py +0 -0
  42. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_task.py +0 -0
  43. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_task_factories.py +0 -0
  44. {ttasks-0.3.1 → ttasks-0.4.0}/tests/test_workflow.py +0 -0
  45. {ttasks-0.3.1 → ttasks-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ttasks
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: github-copilot-sdk>=0.1.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.3.1'
22
- __version_tuple__ = version_tuple = (0, 3, 1)
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