Python: Fix response metadata construction (#6955)

* Python: Fix response metadata construction

Propagate complete AgentResponse metadata through core response construction and provider finalization paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Refine Ollama response metadata parsing

Filter Ollama usage details to real token counts and only propagate streaming finish metadata from final chunks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Address response metadata review comments

Accumulate Copilot non-streaming usage events and keep structured response value parsing lazy for provider hooks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-07-08 11:05:50 +02:00
committed by GitHub
parent 7f551f057a
commit e26c8591ae
10 changed files with 755 additions and 87 deletions
@@ -23,6 +23,7 @@ from agent_framework import (
Message,
ResponseStream,
ToolTypes,
UsageDetails,
load_settings,
normalize_messages,
normalize_tools,
@@ -377,6 +378,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
self._default_options = opts
self._started = False
self._current_session_id: str | None = None
self._structured_output: Any = None
def _normalize_tools(
self,
@@ -685,8 +687,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
Returns:
An AgentResponse with structured_output set as value if present.
"""
structured_output = getattr(self, "_structured_output", None)
return AgentResponse.from_updates(updates, value=structured_output)
return AgentResponse.from_updates(updates, value=self._structured_output)
@overload
def run(
@@ -823,6 +824,34 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
raise AgentException(f"Claude API error: {error_msg}")
session_id = message.session_id
structured_output = message.structured_output
usage = message.usage or {}
input_tokens = usage.get("input_tokens")
output_tokens = usage.get("output_tokens")
total_token_count = (
input_tokens + output_tokens
if isinstance(input_tokens, int) and isinstance(output_tokens, int)
else None
)
usage_details = UsageDetails(**{
key: value
for key, value in {
"input_token_count": input_tokens,
"output_token_count": output_tokens,
"total_token_count": total_token_count,
"cache_creation_input_token_count": usage.get("cache_creation_input_tokens"),
"cache_read_input_token_count": usage.get("cache_read_input_tokens"),
}.items()
if isinstance(value, int)
})
finish_reason = message.stop_reason
if usage_details or finish_reason:
yield AgentResponseUpdate(
contents=[Content.from_usage(usage_details, raw_representation=message)]
if usage_details
else None,
finish_reason=cast(Any, finish_reason),
raw_representation=message,
)
# Update session with session ID
if session_id:
@@ -271,6 +271,55 @@ class TestClaudeAgentRun:
await agent.run("Hello", session=session)
assert session.service_session_id == "test-session-id"
async def test_run_captures_result_message_usage_and_finish_reason(self) -> None:
"""Test that ResultMessage metadata is propagated to the final AgentResponse."""
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
from claude_agent_sdk.types import StreamEvent
messages = [
StreamEvent(
event={
"type": "content_block_delta",
"delta": {"type": "text_delta", "text": "Response"},
},
uuid="event-1",
session_id="test-session-id",
),
AssistantMessage(
content=[TextBlock(text="Response")],
model="claude-sonnet",
),
ResultMessage(
subtype="success",
duration_ms=100,
duration_api_ms=50,
is_error=False,
num_turns=1,
session_id="test-session-id",
stop_reason="end_turn",
usage={
"input_tokens": 42,
"output_tokens": 18,
"cache_creation_input_tokens": 3,
"cache_read_input_tokens": 5,
},
),
]
mock_client = self._create_mock_client(messages)
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
agent = ClaudeAgent()
response = await agent.run("Hello")
assert response.finish_reason == "end_turn"
assert response.usage_details == {
"input_token_count": 42,
"output_token_count": 18,
"total_token_count": 60,
"cache_creation_input_token_count": 3,
"cache_read_input_token_count": 5,
}
async def test_run_with_session(self) -> None:
"""Test run with existing session."""
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
@@ -378,6 +427,51 @@ class TestClaudeAgentRunStream:
assert updates[0].text == "Streaming "
assert updates[1].text == "response"
async def test_run_stream_final_response_captures_usage_and_finish_reason(self) -> None:
"""Test run(stream=True) final response includes ResultMessage metadata."""
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
from claude_agent_sdk.types import StreamEvent
messages = [
StreamEvent(
event={
"type": "content_block_delta",
"delta": {"type": "text_delta", "text": "Streaming response"},
},
uuid="event-1",
session_id="stream-session",
),
AssistantMessage(
content=[TextBlock(text="Streaming response")],
model="claude-sonnet",
),
ResultMessage(
subtype="success",
duration_ms=100,
duration_api_ms=50,
is_error=False,
num_turns=1,
session_id="stream-session",
stop_reason="max_tokens",
usage={"input_tokens": 7, "output_tokens": 9},
),
]
mock_client = self._create_mock_client(messages)
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
agent = ClaudeAgent()
stream = agent.run("Hello", stream=True)
async for _ in stream:
pass
response = await stream.get_final_response()
assert response.finish_reason == "max_tokens"
assert response.usage_details == {
"input_token_count": 7,
"output_token_count": 9,
"total_token_count": 16,
}
async def test_run_stream_raises_on_assistant_message_error(self) -> None:
"""Test run raises AgentException when AssistantMessage has an error."""
from agent_framework.exceptions import AgentException
+24 -63
View File
@@ -50,6 +50,7 @@ from ._types import (
ChatResponseUpdate,
Message,
ResponseStream,
_build_agent_response_from_chat_response, # pyright: ignore[reportPrivateUsage]
map_chat_to_agent_update,
normalize_messages,
)
@@ -1082,24 +1083,28 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]):
if not response:
raise AgentInvalidResponseException("Chat client did not return a response.")
await self._finalize_response(
response=response,
agent_name=context["agent_name"],
session=context["session"],
session_context=context["session_context"],
for message in response.messages:
if message.author_name is None:
message.author_name = context["agent_name"]
session = context["session"]
if (
session
and response.conversation_id
and not is_local_history_conversation_id(response.conversation_id)
and session.service_session_id != response.conversation_id
):
session.service_session_id = response.conversation_id
agent_response = _build_agent_response_from_chat_response(
response,
response_format=context["chat_options"].get("response_format"),
suppress_response_id=context["suppress_response_id"],
)
return AgentResponse(
messages=response.messages,
response_id=None if context["suppress_response_id"] else response.response_id,
created_at=response.created_at,
usage_details=response.usage_details,
value=response.value,
response_format=context["chat_options"].get("response_format"),
continuation_token=response.continuation_token,
raw_representation=response,
additional_properties=response.additional_properties,
)
session_context = context["session_context"]
session_context._response = agent_response # type: ignore[assignment]
await self._run_after_providers(session=session, context=session_context)
return agent_response
def _parse_streaming_response(
self,
@@ -1127,12 +1132,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]):
):
session.service_session_id = conversation_id
suppress_response_id = context["suppress_response_id"]
session_context = context["session_context"]
session_context._response = AgentResponse( # type: ignore[assignment]
messages=response.messages,
response_id=None if suppress_response_id else response.response_id,
)
if context["suppress_response_id"]:
response.response_id = None
session_context._response = response # type: ignore[assignment]
await self._run_after_providers(session=session, context=session_context)
def _propagate_conversation_id(update: AgentResponseUpdate) -> AgentResponseUpdate:
@@ -1430,48 +1433,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]):
"function_invocation_kwargs": additional_function_arguments,
}
async def _finalize_response(
self,
response: ChatResponse,
agent_name: str,
session: AgentSession | None,
session_context: SessionContext,
suppress_response_id: bool = False,
) -> None:
"""Finalize response by setting author names and running after_run providers.
Args:
response: The chat response to finalize.
agent_name: The name of the agent to set as author.
session: The conversation session.
session_context: The invocation context.
suppress_response_id: When True, omit the raw service response ID from the public response.
"""
# Ensure that the author name is set for each message in the response.
for message in response.messages:
if message.author_name is None:
message.author_name = agent_name
# Propagate conversation_id back to session (e.g. thread ID from Assistants API).
# For Responses-style APIs this can rotate every turn (response_id-based continuation),
# so refresh when a newer value is returned.
if (
session
and response.conversation_id
and not is_local_history_conversation_id(response.conversation_id)
and session.service_session_id != response.conversation_id
):
session.service_session_id = response.conversation_id
# Set the response on the context for after_run providers
session_context._response = AgentResponse( # type: ignore[assignment]
messages=response.messages,
response_id=None if suppress_response_id else response.response_id,
)
# Run after_run providers (reverse order)
await self._run_after_providers(session=session, context=session_context)
async def _prepare_session_and_messages(
self,
*,
@@ -29,7 +29,13 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast
from ._feature_stage import ExperimentalFeature, experimental
from ._middleware import ChatContext, ChatMiddleware
from ._types import AgentResponse, ChatResponse, Message, ResponseStream
from ._types import (
AgentResponse,
ChatResponse,
Message,
ResponseStream,
_build_agent_response_from_chat_response, # pyright: ignore[reportPrivateUsage]
)
from .exceptions import ChatClientInvalidResponseException
if TYPE_CHECKING:
@@ -634,9 +640,9 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
response: ChatResponse,
) -> None:
"""Persist a single model-call response through the configured history providers."""
service_call_context._response = AgentResponse( # type: ignore[assignment]
messages=response.messages,
response_id=None,
service_call_context._response = _build_agent_response_from_chat_response( # type: ignore[assignment]
response,
suppress_response_id=True,
)
for provider in reversed(self._providers):
await provider.after_run(
@@ -2782,6 +2782,30 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
return self.text
def _build_agent_response_from_chat_response( # pyright: ignore[reportUnusedFunction]
response: ChatResponse[Any],
*,
response_format: StructuredResponseFormat = None,
suppress_response_id: bool = False,
) -> AgentResponse[Any]:
"""Build the AgentResponse wrapper for a completed ChatResponse."""
agent_response = AgentResponse(
messages=response.messages,
response_id=None if suppress_response_id else response.response_id,
created_at=response.created_at,
finish_reason=cast(FinishReasonLiteral | FinishReason | None, response.finish_reason),
usage_details=response.usage_details,
response_format=response_format,
continuation_token=response.continuation_token,
raw_representation=response,
additional_properties=response.additional_properties,
)
if response._value_parsed: # pyright: ignore[reportPrivateUsage]
agent_response._value = response._value # pyright: ignore[reportPrivateUsage]
agent_response._value_parsed = True # pyright: ignore[reportPrivateUsage]
return agent_response
# region AgentResponseUpdate
@@ -122,6 +122,24 @@ class _ResponseIdRecordingHistoryProvider(_RecordingHistoryProvider):
await super().after_run(agent=agent, session=session, context=context, state=state)
class _ResponseMetadataRecordingHistoryProvider(_RecordingHistoryProvider):
def __init__(self, source_id: str = "recording_response_metadata") -> None:
super().__init__(source_id=source_id)
self.responses: list[AgentResponse] = []
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
if context.response:
self.responses.append(context.response)
await super().after_run(agent=agent, session=session, context=context, state=state)
def test_agent_session_type(agent_session: AgentSession) -> None:
assert isinstance(agent_session, AgentSession)
@@ -493,6 +511,64 @@ async def test_chat_agent_persists_history_per_service_call(
assert session.service_session_id is None
async def test_per_service_call_history_provider_receives_full_agent_response_metadata(
chat_client_base: SupportsChatGetResponse,
) -> None:
provider = _ResponseMetadataRecordingHistoryProvider()
@tool(name="lookup_weather", approval_mode="never_require")
def lookup_weather(location: str) -> str:
return f"Weather in {location}: sunny"
session = AgentSession()
session.state[provider.source_id] = {"messages": []}
first_response = ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_1",
name="lookup_weather",
arguments='{"location": "Seattle"}',
)
],
),
response_id="resp_call_1",
created_at="2026-07-07T12:02:00Z",
finish_reason="tool_calls",
usage_details={"input_token_count": 5, "output_token_count": 1, "total_token_count": 6},
continuation_token=cast(Any, {"token": "psc-next"}),
additional_properties={"provider": "psc-test"},
)
final_response = ChatResponse(
messages=Message(role="assistant", contents=["It is sunny in Seattle."]),
response_id="resp_call_2",
finish_reason="stop",
usage_details={"input_token_count": 6, "output_token_count": 4, "total_token_count": 10},
)
chat_client_base.run_responses = [first_response, final_response] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
agent = Agent(
client=chat_client_base,
tools=[lookup_weather],
context_providers=[provider],
require_per_service_call_history_persistence=True,
)
result = await agent.run("What's the weather in Seattle?", session=session)
assert result.text == "It is sunny in Seattle."
assert len(provider.responses) == 2
captured = provider.responses[0]
assert captured.response_id is None
assert captured.created_at == "2026-07-07T12:02:00Z"
assert captured.finish_reason == "tool_calls"
assert captured.usage_details == {"input_token_count": 5, "output_token_count": 1, "total_token_count": 6}
assert captured.continuation_token == {"token": "psc-next"}
assert captured.additional_properties == {"provider": "psc-test"}
assert captured.raw_representation is first_response
async def test_chat_agent_persists_history_per_service_call_streaming(
chat_client_base: SupportsChatGetResponse,
) -> None:
@@ -1182,6 +1258,144 @@ async def test_chat_agent_context_providers_after_run(
assert mock_provider.last_service_session_id == "test-thread-id"
async def test_context_provider_receives_full_agent_response_non_streaming(
chat_client_base: SupportsChatGetResponse,
) -> None:
"""Context providers should see the same full AgentResponse returned by non-streaming runs."""
captured_response: AgentResponse | None = None
class CapturingContextProvider(ContextProvider):
def __init__(self) -> None:
super().__init__(source_id="capture_response")
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
nonlocal captured_response
captured_response = context.response
raw_response = ChatResponse(
messages=[Message(role="assistant", contents=[Content.from_text('{"answer": "ok"}')])],
response_id="resp_full",
created_at="2026-07-07T12:00:00Z",
finish_reason="stop",
usage_details={"input_token_count": 3, "output_token_count": 2, "total_token_count": 5},
continuation_token=cast(Any, {"token": "next"}),
additional_properties={"provider": "test"},
)
chat_client_base.run_responses = [raw_response] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
agent = Agent(client=chat_client_base, context_providers=[CapturingContextProvider()])
response = await agent.run(
"Hello",
options={"response_format": {"type": "object", "properties": {"answer": {"type": "string"}}}},
)
assert captured_response is response
assert response.response_id == "resp_full"
assert response.created_at == "2026-07-07T12:00:00Z"
assert response.finish_reason == "stop"
assert response.usage_details == {"input_token_count": 3, "output_token_count": 2, "total_token_count": 5}
assert response.value == {"answer": "ok"}
assert response.continuation_token == {"token": "next"}
assert response.additional_properties == {"provider": "test"}
assert response.raw_representation is raw_response
async def test_context_provider_after_run_preserves_lazy_structured_value_parsing(
chat_client_base: SupportsChatGetResponse,
) -> None:
"""Context providers should see the response before malformed structured output raises for callers."""
captured_response: AgentResponse | None = None
class CapturingContextProvider(ContextProvider):
def __init__(self) -> None:
super().__init__(source_id="capture_lazy_value_response")
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
nonlocal captured_response
captured_response = context.response
raw_response = ChatResponse(messages=[Message(role="assistant", contents=[Content.from_text("not-json")])])
chat_client_base.run_responses = [raw_response] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
agent = Agent(client=chat_client_base, context_providers=[CapturingContextProvider()])
response = await agent.run("Hello", options={"response_format": {"type": "object"}})
assert captured_response is response
assert response.text == "not-json"
with pytest.raises(ValueError, match="not valid JSON"):
_ = response.value
async def test_context_provider_receives_full_agent_response_streaming(
chat_client_base: SupportsChatGetResponse,
) -> None:
"""Context providers should see the same full AgentResponse finalized by streaming runs."""
captured_response: AgentResponse | None = None
raw_update = ChatResponseUpdate(
contents=[
Content.from_text('{"answer": "ok"}'),
Content.from_usage({"input_token_count": 4, "output_token_count": 3, "total_token_count": 7}),
],
role="assistant",
response_id="resp_stream_full",
created_at="2026-07-07T12:01:00Z",
finish_reason="stop",
continuation_token=cast(Any, {"token": "stream-next"}),
additional_properties={"provider": "stream-test"},
)
class CapturingContextProvider(ContextProvider):
def __init__(self) -> None:
super().__init__(source_id="capture_stream_response")
async def after_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
nonlocal captured_response
captured_response = context.response
chat_client_base.streaming_responses = [[raw_update]] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
agent = Agent(client=chat_client_base, context_providers=[CapturingContextProvider()])
stream = agent.run(
"Hello",
stream=True,
options={"response_format": {"type": "object", "properties": {"answer": {"type": "string"}}}},
)
async for _ in stream:
pass
response = await stream.get_final_response()
assert captured_response is response
assert response.response_id == "resp_stream_full"
assert response.created_at == "2026-07-07T12:01:00Z"
assert response.finish_reason == "stop"
assert response.usage_details == {"input_token_count": 4, "output_token_count": 3, "total_token_count": 7}
assert response.value == {"answer": "ok"}
assert response.continuation_token == {"token": "stream-next"}
assert response.additional_properties == {"provider": "stream-test"}
assert response.raw_representation == [raw_update]
async def test_chat_agent_context_providers_messages_adding(
client: SupportsChatGetResponse,
) -> None:
@@ -9,12 +9,7 @@ import logging
import sys
import warnings
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
from typing import Any, ClassVar, Generic, Literal, TypedDict, overload
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload
from agent_framework import (
AgentMiddlewareLayer,
@@ -29,6 +24,8 @@ from agent_framework import (
Message,
ResponseStream,
SessionContext,
UsageDetails,
add_usage_details,
normalize_messages,
)
from agent_framework._settings import load_settings
@@ -37,6 +34,15 @@ from agent_framework._types import AgentRunInputs, normalize_tools
from agent_framework.exceptions import AgentException
from agent_framework.observability import AgentTelemetryLayer
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
try:
from copilot import CopilotClient, CopilotSession, RuntimeConnection
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
@@ -49,7 +55,7 @@ try:
SessionHooks,
SystemMessageConfig,
)
from copilot.session_events import PermissionRequest, SessionEvent, SessionEventType
from copilot.session_events import AssistantUsageData, PermissionRequest, SessionEvent, SessionEventType
from copilot.tools import Tool as CopilotTool
from copilot.tools import ToolInvocation, ToolResult
except ImportError as _copilot_import_error:
@@ -58,12 +64,6 @@ except ImportError as _copilot_import_error:
"Please use Python 3.11 or later."
) from _copilot_import_error
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
DEFAULT_TIMEOUT_SECONDS: float = 60.0
"""Default timeout in seconds for Copilot requests."""
@@ -563,6 +563,27 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
)
return self._run_impl(messages=messages, session=session, options=options)
@staticmethod
def _parse_usage_details_from_copilot(data: AssistantUsageData) -> UsageDetails | None:
total_token_count = (
data.input_tokens + data.output_tokens
if data.input_tokens is not None and data.output_tokens is not None
else None
)
usage_details = UsageDetails(**{
key: value
for key, value in {
"input_token_count": data.input_tokens,
"output_token_count": data.output_tokens,
"total_token_count": total_token_count,
"cache_read_input_token_count": data.cache_read_tokens,
"cache_creation_input_token_count": data.cache_write_tokens,
"reasoning_output_token_count": data.reasoning_tokens,
}.items()
if value is not None
})
return usage_details or None
async def _run_impl(
self,
messages: AgentRunInputs | None = None,
@@ -602,6 +623,27 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
opts["tools"] = existing + list(session_context.tools)
copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts)
usage_details: UsageDetails | None = None
finish_reason: str | None = None
model: str | None = None
def usage_event_handler(event: SessionEvent) -> None:
nonlocal usage_details, finish_reason, model
if event.type != SessionEventType.ASSISTANT_USAGE:
return
if isinstance(event.data, AssistantUsageData):
parsed_usage_details = self._parse_usage_details_from_copilot(event.data)
if parsed_usage_details:
usage_details = add_usage_details(usage_details, parsed_usage_details)
if event.data.finish_reason:
finish_reason = event.data.finish_reason
if event.data.model:
model = event.data.model
else:
logger.warning(
"Ignoring GitHub Copilot assistant usage event with unexpected payload type: %s",
type(event.data).__name__,
)
# Build the prompt from the full set of messages in the session context,
# so that any context/history provider-injected messages are included.
@@ -610,10 +652,13 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
if session_context.instructions:
prompt = "\n".join(session_context.instructions) + "\n" + prompt
unsubscribe = copilot_session.on(usage_event_handler)
try:
response_event = await copilot_session.send_and_wait(prompt, timeout=timeout)
except Exception as ex:
raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
finally:
unsubscribe()
response_messages: list[Message] = []
response_id: str | None = None
@@ -635,7 +680,13 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
)
response_id = message_id
response = AgentResponse(messages=response_messages, response_id=response_id)
response = AgentResponse(
messages=response_messages,
response_id=response_id,
finish_reason=cast(Any, finish_reason),
usage_details=usage_details,
additional_properties={"model": model} if model else None,
)
session_context._response = response # type: ignore[assignment]
await self._run_after_providers(session=session, context=session_context)
return response
@@ -721,6 +772,26 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
raw_representation=event,
)
queue.put_nowait(update)
elif event.type == SessionEventType.ASSISTANT_USAGE:
if not isinstance(event.data, AssistantUsageData):
logger.warning(
"Ignoring GitHub Copilot assistant usage event with unexpected payload type: %s",
type(event.data).__name__,
)
return
usage_details = self._parse_usage_details_from_copilot(event.data)
finish_reason = event.data.finish_reason or None
model = event.data.model or None
if usage_details or finish_reason or model:
update = AgentResponseUpdate(
contents=[Content.from_usage(usage_details, raw_representation=event.data)]
if usage_details
else None,
finish_reason=cast(Any, finish_reason),
additional_properties={"model": model} if model else None,
raw_representation=event,
)
queue.put_nowait(update)
elif event.type == SessionEventType.TOOL_EXECUTION_START:
tool_call_id = getattr(event.data, "tool_call_id", None) or ""
tool_name = getattr(event.data, "tool_name", None) or ""
@@ -27,6 +27,7 @@ from agent_framework import (
from agent_framework.exceptions import AgentException
from copilot.session import PermissionHandler, PreToolUseHookInput
from copilot.session_events import (
AssistantUsageData,
Data,
SessionEvent,
SessionEventType,
@@ -459,6 +460,68 @@ class TestGitHubCopilotAgentRun:
assert isinstance(response, AgentResponse)
async def test_run_captures_assistant_usage_event(
self,
mock_client: MagicMock,
mock_session: MagicMock,
assistant_message_event: SessionEvent,
) -> None:
"""Test non-streaming run captures assistant usage metadata from session events."""
usage_data = AssistantUsageData(
model="claude-sonnet-4-5",
input_tokens=120,
output_tokens=40,
cache_read_tokens=7,
cache_write_tokens=3,
reasoning_tokens=11,
finish_reason="stop",
)
usage_event = SessionEvent(
data=usage_data,
id=uuid4(),
timestamp=datetime.now(timezone.utc),
type=SessionEventType.ASSISTANT_USAGE,
)
second_usage_event = SessionEvent(
data=AssistantUsageData(
model="gpt-5.1-mini",
input_tokens=5,
output_tokens=2,
finish_reason="length",
),
id=uuid4(),
timestamp=datetime.now(timezone.utc),
type=SessionEventType.ASSISTANT_USAGE,
)
usage_handler: Any = None
def mock_on(handler: Any) -> Any:
nonlocal usage_handler
usage_handler = handler
return lambda: None
async def mock_send_and_wait(*args: Any, **kwargs: Any) -> SessionEvent:
usage_handler(usage_event)
usage_handler(second_usage_event)
return assistant_message_event
mock_session.on = mock_on
mock_session.send_and_wait = AsyncMock(side_effect=mock_send_and_wait)
agent = GitHubCopilotAgent(client=mock_client)
response = await agent.run("Hello")
assert response.finish_reason == "length"
assert response.usage_details == {
"input_token_count": 125,
"output_token_count": 42,
"total_token_count": 167,
"cache_read_input_token_count": 7,
"cache_creation_input_token_count": 3,
"reasoning_output_token_count": 11,
}
assert response.additional_properties["model"] == "gpt-5.1-mini"
async def test_run_empty_response(
self,
mock_client: MagicMock,
@@ -521,6 +584,50 @@ class TestGitHubCopilotAgentRunStreaming:
assert responses[0].role == "assistant"
assert responses[0].contents[0].text == "Hello"
async def test_run_streaming_captures_assistant_usage_event(
self,
mock_client: MagicMock,
mock_session: MagicMock,
assistant_delta_event: SessionEvent,
session_idle_event: SessionEvent,
) -> None:
"""Test streaming final response includes assistant usage metadata."""
usage_data = AssistantUsageData(
model="gpt-5.1-mini",
input_tokens=10,
output_tokens=4,
finish_reason="stop",
)
usage_event = SessionEvent(
data=usage_data,
id=uuid4(),
timestamp=datetime.now(timezone.utc),
type=SessionEventType.ASSISTANT_USAGE,
)
events = [assistant_delta_event, usage_event, session_idle_event]
def mock_on(handler: Any) -> Any:
for event in events:
handler(event)
return lambda: None
mock_session.on = mock_on
agent = GitHubCopilotAgent(client=mock_client)
stream = agent.run("Hello", stream=True)
async for _ in stream:
pass
response = await stream.get_final_response()
assert response.text == "Hello"
assert response.finish_reason == "stop"
assert response.usage_details == {
"input_token_count": 10,
"output_token_count": 4,
"total_token_count": 14,
}
assert response.additional_properties["model"] == "gpt-5.1-mini"
async def test_run_streaming_with_session(
self,
mock_client: MagicMock,
@@ -540,11 +540,30 @@ class OllamaChatClient(
def _parse_streaming_response_from_ollama(self, response: OllamaChatResponse) -> ChatResponseUpdate:
contents = self._parse_contents_from_ollama(response)
finish_reason = None
if response.done:
usage_details = UsageDetails(
**{
key: value
for key, value in {
"input_token_count": response.prompt_eval_count,
"output_token_count": response.eval_count,
"total_token_count": response.prompt_eval_count + response.eval_count
if isinstance(response.prompt_eval_count, int) and isinstance(response.eval_count, int)
else None,
}.items()
if isinstance(value, int)
}
)
if usage_details:
contents.append(Content.from_usage(usage_details, raw_representation=response))
finish_reason = response.done_reason if response.done_reason in ("stop", "length") else None
return ChatResponseUpdate(
contents=contents,
role="assistant",
model=response.model,
created_at=response.created_at,
finish_reason=finish_reason,
)
def _parse_response_from_ollama(
@@ -554,15 +573,27 @@ class OllamaChatClient(
response_format: Any | None = None,
) -> ChatResponse:
contents = self._parse_contents_from_ollama(response)
usage_details = UsageDetails(
**{
key: value
for key, value in {
"input_token_count": response.prompt_eval_count,
"output_token_count": response.eval_count,
"total_token_count": response.prompt_eval_count + response.eval_count
if isinstance(response.prompt_eval_count, int) and isinstance(response.eval_count, int)
else None,
}.items()
if isinstance(value, int)
}
)
finish_reason = response.done_reason if response.done_reason in ("stop", "length") else None
return ChatResponse(
messages=[Message(role="assistant", contents=contents)],
model=response.model,
created_at=response.created_at,
usage_details=UsageDetails(
input_token_count=response.prompt_eval_count,
output_token_count=response.eval_count,
),
finish_reason=finish_reason,
usage_details=usage_details or None,
response_format=response_format,
)
@@ -262,6 +262,71 @@ async def test_cmc(
assert result.text == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_maps_done_reason_to_finish_reason(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
mock_chat.return_value = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
eval_count=2,
prompt_eval_count=3,
done_reason="length",
)
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert result.finish_reason == "length"
assert result.usage_details == {
"input_token_count": 3,
"output_token_count": 2,
"total_token_count": 5,
}
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_leaves_unknown_done_reason_unset(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
mock_chat.return_value = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
done_reason="load",
)
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert result.finish_reason is None
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_omits_usage_when_token_counts_are_missing(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
mock_chat.return_value = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
done_reason="stop",
)
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history)
assert result.finish_reason == "stop"
assert not result.usage_details
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_response_format_dict(
mock_chat: AsyncMock,
@@ -380,6 +445,72 @@ async def test_cmc_streaming(
assert chunk.text == "test"
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming_maps_done_reason_and_usage(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
response = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
done=True,
done_reason="stop",
eval_count=4,
prompt_eval_count=6,
created_at="2024-01-01T00:00:00Z",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [response]
mock_chat.return_value = stream
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_response(messages=chat_history, stream=True)
async for _ in result:
pass
final_response = await result.get_final_response()
assert final_response.text == "test"
assert final_response.finish_reason == "stop"
assert final_response.usage_details == {
"input_token_count": 6,
"output_token_count": 4,
"total_token_count": 10,
}
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming_ignores_done_reason_and_usage_before_final_chunk(
mock_chat: AsyncMock,
ollama_unit_test_env: dict[str, str],
chat_history: list[Message],
) -> None:
response = OllamaChatResponse(
message=OllamaMessage(content="test", role="assistant"),
model="test",
done=False,
done_reason="stop",
eval_count=4,
prompt_eval_count=6,
created_at="2024-01-01T00:00:00Z",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [response]
mock_chat.return_value = stream
chat_history.append(Message(contents=["hello world"], role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_response(messages=chat_history, stream=True)
async for _ in result:
pass
final_response = await result.get_final_response()
assert final_response.text == "test"
assert final_response.finish_reason is None
assert final_response.usage_details is None
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc_streaming_reasoning(
mock_chat: AsyncMock,