Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ async def main():
session.on(on_event)

# Send a message and wait for completion
await session.send({"prompt": "What is 2+2?"})
await session.send("What is 2+2?")
await done.wait()

# Clean up
Expand All @@ -61,7 +61,7 @@ Sessions also support the `async with` context manager pattern for automatic cle

```python
async with await client.create_session({"model": "gpt-5"}) as session:
await session.send({"prompt": "What is 2+2?"})
await session.send("What is 2+2?")
# session is automatically disconnected when leaving the block
```

Expand Down Expand Up @@ -91,7 +91,7 @@ def on_event(event):
print(f"Event: {event['type']}")

session.on(on_event)
await session.send({"prompt": "Hello!"})
await session.send("Hello!")

# ... wait for events ...

Expand Down Expand Up @@ -266,21 +266,21 @@ async def safe_lookup(params: LookupParams) -> str:
The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path:

```python
await session.send({
"prompt": "What's in this image?",
"attachments": [
await session.send(
"What's in this image?",
attachments=[
{
"type": "file",
"path": "/path/to/image.jpg",
}
]
})
],
)
```

Supported image formats include JPG, PNG, GIF, and other common image types. The agent's `view` tool can also read images directly from the filesystem, so you can also ask questions like:

```python
await session.send({"prompt": "What does the most recent jpg in this directory portray?"})
await session.send("What does the most recent jpg in this directory portray?")
```

## Streaming
Expand Down Expand Up @@ -325,7 +325,7 @@ async def main():
done.set()

session.on(on_event)
await session.send({"prompt": "Tell me a short story"})
await session.send("Tell me a short story")
await done.wait() # Wait for streaming to complete

await session.disconnect()
Expand Down Expand Up @@ -402,7 +402,7 @@ session = await client.create_session({
},
})

await session.send({"prompt": "Hello!"})
await session.send("Hello!")
```

**Example with custom OpenAI-compatible API:**
Expand Down
2 changes: 0 additions & 2 deletions python/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
MCPLocalServerConfig,
MCPRemoteServerConfig,
MCPServerConfig,
MessageOptions,
ModelBilling,
ModelCapabilities,
ModelInfo,
Expand Down Expand Up @@ -56,7 +55,6 @@
"MCPLocalServerConfig",
"MCPRemoteServerConfig",
"MCPServerConfig",
"MessageOptions",
"ModelBilling",
"ModelCapabilities",
"ModelInfo",
Expand Down
4 changes: 2 additions & 2 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
>>>
>>> async with CopilotClient() as client:
... session = await client.create_session()
... await session.send({"prompt": "Hello!"})
... await session.send("Hello!")
"""

import asyncio
Expand Down Expand Up @@ -104,7 +104,7 @@ class CopilotClient:
... "model": "gpt-4",
... })
>>> session.on(lambda event: print(event.type))
>>> await session.send({"prompt": "Hello!"})
>>> await session.send("Hello!")
>>>
>>> # Clean up
>>> await session.disconnect()
Expand Down
68 changes: 40 additions & 28 deletions python/copilot/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import inspect
import threading
from collections.abc import Callable
from typing import Any, cast
from typing import Any, Literal, cast

from .generated.rpc import (
Kind,
Expand All @@ -26,7 +26,7 @@
from .jsonrpc import JsonRpcError, ProcessExitedError
from .telemetry import get_trace_context, trace_context
from .types import (
MessageOptions,
Attachment,
PermissionRequest,
PermissionRequestResult,
SessionHooks,
Expand Down Expand Up @@ -64,7 +64,7 @@ class CopilotSession:
... unsubscribe = session.on(lambda event: print(event.type))
...
... # Send a message
... await session.send({"prompt": "Hello, world!"})
... await session.send("Hello, world!")
...
... # Clean up
... unsubscribe()
Expand Down Expand Up @@ -116,45 +116,57 @@ def workspace_path(self) -> str | None:
"""
return self._workspace_path

async def send(self, options: MessageOptions) -> str:
async def send(
self,
prompt: str,
*,
attachments: list[Attachment] | None = None,
mode: Literal["enqueue", "immediate"] | None = None,
) -> str:
"""
Send a message to this session and wait for the response.
Send a message to this session.

The message is processed asynchronously. Subscribe to events via :meth:`on`
to receive streaming responses and other session events.
to receive streaming responses and other session events. Use
:meth:`send_and_wait` to block until the assistant finishes processing.

Args:
options: Message options including the prompt and optional attachments.
Must contain a "prompt" key with the message text. Can optionally
include "attachments" and "mode" keys.
prompt: The message text to send.
attachments: Optional file, directory, or selection attachments.
mode: Message delivery mode (``"enqueue"`` or ``"immediate"``).

Returns:
The message ID of the response, which can be used to correlate events.
The message ID assigned by the server, which can be used to correlate events.

Raises:
Exception: If the session has been disconnected or the connection fails.

Example:
>>> message_id = await session.send({
... "prompt": "Explain this code",
... "attachments": [{"type": "file", "path": "./src/main.py"}]
... })
>>> message_id = await session.send(
... "Explain this code",
... attachments=[{"type": "file", "path": "./src/main.py"}],
... )
"""
params: dict[str, Any] = {
"sessionId": self.session_id,
"prompt": options["prompt"],
"prompt": prompt,
}
if "attachments" in options:
params["attachments"] = options["attachments"]
if "mode" in options:
params["mode"] = options["mode"]
if attachments is not None:
params["attachments"] = attachments
if mode is not None:
params["mode"] = mode
params.update(get_trace_context())

response = await self._client.request("session.send", params)
return response["messageId"]

async def send_and_wait(
self, options: MessageOptions, timeout: float | None = None
self,
prompt: str,
*,
attachments: list[Attachment] | None = None,
mode: Literal["enqueue", "immediate"] | None = None,
timeout: float = 60.0,
) -> SessionEvent | None:
"""
Send a message to this session and wait until the session becomes idle.
Expand All @@ -166,7 +178,9 @@ async def send_and_wait(
Events are still delivered to handlers registered via :meth:`on` while waiting.

Args:
options: Message options including the prompt and optional attachments.
prompt: The message text to send.
attachments: Optional file, directory, or selection attachments.
mode: Message delivery mode (``"enqueue"`` or ``"immediate"``).
timeout: Timeout in seconds (default: 60). Controls how long to wait;
does not abort in-flight agent work.

Expand All @@ -178,12 +192,10 @@ async def send_and_wait(
Exception: If the session has been disconnected or the connection fails.

Example:
>>> response = await session.send_and_wait({"prompt": "What is 2+2?"})
>>> response = await session.send_and_wait("What is 2+2?")
>>> if response:
... print(response.data.content)
"""
effective_timeout = timeout if timeout is not None else 60.0

idle_event = asyncio.Event()
error_event: Exception | None = None
last_assistant_message: SessionEvent | None = None
Expand All @@ -202,13 +214,13 @@ def handler(event: SessionEventTypeAlias) -> None:

unsubscribe = self.on(handler)
try:
await self.send(options)
await asyncio.wait_for(idle_event.wait(), timeout=effective_timeout)
await self.send(prompt, attachments=attachments, mode=mode)
await asyncio.wait_for(idle_event.wait(), timeout=timeout)
if error_event:
raise error_event
return last_assistant_message
except TimeoutError:
raise TimeoutError(f"Timeout after {effective_timeout}s waiting for session.idle")
raise TimeoutError(f"Timeout after {timeout}s waiting for session.idle")
finally:
unsubscribe()

Expand Down Expand Up @@ -719,7 +731,7 @@ async def abort(self) -> None:
>>>
>>> # Start a long-running request
>>> task = asyncio.create_task(
... session.send({"prompt": "Write a very long story..."})
... session.send("Write a very long story...")
... )
>>>
>>> # Abort after 5 seconds
Expand Down
11 changes: 0 additions & 11 deletions python/copilot/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,17 +654,6 @@ class ResumeSessionConfig(TypedDict, total=False):
on_event: Callable[[SessionEvent], None]


# Options for sending a message to a session
class MessageOptions(TypedDict):
"""Options for sending a message to a session"""

prompt: str # The prompt/message to send
# Optional file/directory attachments
attachments: NotRequired[list[Attachment]]
# Message processing mode
mode: NotRequired[Literal["enqueue", "immediate"]]


# Event handler type
SessionEventHandler = Callable[[SessionEvent], None]

Expand Down
2 changes: 1 addition & 1 deletion python/e2e/test_agent_and_compact_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ async def test_should_compact_session_history_after_messages(self, ctx: E2ETestC
)

# Send a message to create some history
await session.send_and_wait({"prompt": "What is 2+2?"})
await session.send_and_wait("What is 2+2?")

# Compact the session
result = await session.rpc.compaction.compact()
Expand Down
24 changes: 6 additions & 18 deletions python/e2e/test_ask_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,8 @@ async def on_user_input_request(request, invocation):
)

await session.send_and_wait(
{
"prompt": (
"Ask me to choose between 'Option A' and 'Option B' using the ask_user "
"tool. Wait for my response before continuing."
)
}
"Ask me to choose between 'Option A' and 'Option B' using the ask_user "
"tool. Wait for my response before continuing."
)

# Should have received at least one user input request
Expand Down Expand Up @@ -76,12 +72,8 @@ async def on_user_input_request(request, invocation):
)

await session.send_and_wait(
{
"prompt": (
"Use the ask_user tool to ask me to pick between exactly two options: "
"'Red' and 'Blue'. These should be provided as choices. Wait for my answer."
)
}
"Use the ask_user tool to ask me to pick between exactly two options: "
"'Red' and 'Blue'. These should be provided as choices. Wait for my answer."
)

# Should have received a request
Expand Down Expand Up @@ -117,12 +109,8 @@ async def on_user_input_request(request, invocation):
)

response = await session.send_and_wait(
{
"prompt": (
"Ask me a question using ask_user and then include my answer in your "
"response. The question should be 'What is your favorite color?'"
)
}
"Ask me a question using ask_user and then include my answer in your "
"response. The question should be 'What is your favorite color?'"
)

# Should have received a request
Expand Down
12 changes: 5 additions & 7 deletions python/e2e/test_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,11 @@ def on_event(event):
session.on(on_event)

# Send multiple messages to fill up the context window
await session.send_and_wait({"prompt": "Tell me a story about a dragon. Be detailed."})
await session.send_and_wait("Tell me a story about a dragon. Be detailed.")
await session.send_and_wait(
{"prompt": "Continue the story with more details about the dragon's castle."}
)
await session.send_and_wait(
{"prompt": "Now describe the dragon's treasure in great detail."}
"Continue the story with more details about the dragon's castle."
)
await session.send_and_wait("Now describe the dragon's treasure in great detail.")

# Should have triggered compaction at least once
assert len(compaction_start_events) >= 1, "Expected at least 1 compaction_start event"
Expand All @@ -62,7 +60,7 @@ def on_event(event):
assert last_complete.data.tokens_removed > 0, "Expected tokensRemoved > 0"

# Verify the session still works after compaction
answer = await session.send_and_wait({"prompt": "What was the story about?"})
answer = await session.send_and_wait("What was the story about?")
assert answer is not None
assert answer.data.content is not None
# Should remember it was about a dragon (context preserved via summary)
Expand All @@ -89,7 +87,7 @@ def on_event(event):

session.on(on_event)

await session.send_and_wait({"prompt": "What is 2+2?"})
await session.send_and_wait("What is 2+2?")

# Should not have any compaction events when disabled
assert len(compaction_events) == 0, "Expected no compaction events when disabled"
Loading