Skip to content

Commit 9d63384

Browse files
Phase D + E: lifecycle polymorphic union + datetime timestamps
Mirrors PR #1357 Phases D + E (TypeScript SDK API review). Phase D — Session lifecycle event polymorphic hierarchy: * Split the flat `SessionLifecycleEvent` dataclass into a `SessionLifecycleEventBase` + five concrete variant dataclasses (`SessionCreatedEvent`, `SessionDeletedEvent`, `SessionUpdatedEvent`, `SessionForegroundEvent`, `SessionBackgroundEvent`). `SessionLifecycleEvent` is now a `Union` type alias over the five variants; pattern-match on the variant class to branch on the event kind. Each variant exposes `type: ClassVar[Literal["..."]]`, so existing `event.type == "session.created"` consumer code continues to work. * `SessionLifecycleEvent.from_dict` becomes a module-private `_session_lifecycle_event_from_dict` factory that switches on the wire `type` and constructs the matching variant. Phase D / E — Timestamps as `datetime`: * `SessionMetadata.startTime` / `modifiedTime` now `datetime` (previously ISO-8601 `str`). `to_dict` round-trips back to ISO strings. * `SessionLifecycleEventMetadata.startTime` / `modifiedTime` likewise. * All `Pre/PostToolUseHookInput`, `PreMcpToolCallHookInput`, `UserPromptSubmittedHookInput`, `SessionStartHookInput`, `SessionEndHookInput`, `ErrorOccurredHookInput` declare `timestamp: datetime`. `CopilotSession._handle_hooks_invoke` converts the wire epoch-milliseconds integer into a timezone-aware `datetime` at the dispatch boundary (same place the existing `cwd → workingDirectory` remap lives). Tests updated in lock-step: `test_session_e2e.py` asserts the new `datetime` types on `SessionMetadata`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0ea0bc7 commit 9d63384

3 files changed

Lines changed: 123 additions & 42 deletions

File tree

python/copilot/client.py

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from datetime import UTC, datetime
3131
from pathlib import Path
3232
from types import TracebackType
33-
from typing import Any, Literal, TypedDict, cast, overload
33+
from typing import Any, ClassVar, Literal, TypedDict, cast, overload
3434

3535
from ._diagnostics import log_timing
3636
from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError
@@ -812,8 +812,8 @@ class SessionMetadata:
812812
"""Metadata about a session"""
813813

814814
sessionId: str # Session identifier
815-
startTime: str # ISO 8601 timestamp when session was created
816-
modifiedTime: str # ISO 8601 timestamp when session was last modified
815+
startTime: datetime # Timestamp when session was created
816+
modifiedTime: datetime # Timestamp when session was last modified
817817
isRemote: bool # Whether the session is remote
818818
summary: str | None = None # Optional summary of the session
819819
context: SessionContext | None = None # Working directory context
@@ -835,8 +835,8 @@ def from_dict(obj: Any) -> SessionMetadata:
835835
context = SessionContext.from_dict(context_dict) if context_dict else None
836836
return SessionMetadata(
837837
sessionId=str(sessionId),
838-
startTime=str(startTime),
839-
modifiedTime=str(modifiedTime),
838+
startTime=_parse_session_timestamp(startTime),
839+
modifiedTime=_parse_session_timestamp(modifiedTime),
840840
isRemote=bool(isRemote),
841841
summary=summary,
842842
context=context,
@@ -845,8 +845,8 @@ def from_dict(obj: Any) -> SessionMetadata:
845845
def to_dict(self) -> dict:
846846
result: dict = {}
847847
result["sessionId"] = self.sessionId
848-
result["startTime"] = self.startTime
849-
result["modifiedTime"] = self.modifiedTime
848+
result["startTime"] = self.startTime.isoformat()
849+
result["modifiedTime"] = self.modifiedTime.isoformat()
850850
result["isRemote"] = self.isRemote
851851
if self.summary is not None:
852852
result["summary"] = self.summary
@@ -855,6 +855,18 @@ def to_dict(self) -> dict:
855855
return result
856856

857857

858+
def _parse_session_timestamp(value: Any) -> datetime:
859+
"""Parse a wire-format timestamp into ``datetime``.
860+
861+
Accepts either an ISO-8601 string (server-sent JSON) or an existing
862+
``datetime`` (round-tripped from a previous parse). Returns the value
863+
as-is if it's already a ``datetime``.
864+
"""
865+
if isinstance(value, datetime):
866+
return value
867+
return from_datetime(value)
868+
869+
858870
# ============================================================================
859871
# Session Lifecycle Types (for TUI+server mode)
860872
# ============================================================================
@@ -872,37 +884,95 @@ def to_dict(self) -> dict:
872884
class SessionLifecycleEventMetadata:
873885
"""Metadata for session lifecycle events."""
874886

875-
startTime: str
876-
modifiedTime: str
887+
startTime: datetime
888+
modifiedTime: datetime
877889
summary: str | None = None
878890

879891
@staticmethod
880892
def from_dict(data: dict) -> SessionLifecycleEventMetadata:
881893
return SessionLifecycleEventMetadata(
882-
startTime=data.get("startTime", ""),
883-
modifiedTime=data.get("modifiedTime", ""),
894+
startTime=_parse_session_timestamp(data.get("startTime", "")),
895+
modifiedTime=_parse_session_timestamp(data.get("modifiedTime", "")),
884896
summary=data.get("summary"),
885897
)
886898

887899

888900
@dataclass
889-
class SessionLifecycleEvent:
890-
"""Session lifecycle event notification."""
901+
class SessionLifecycleEventBase:
902+
"""Base for session lifecycle event variants.
903+
904+
Construct concrete variants directly (e.g. :class:`SessionCreatedEvent`,
905+
:class:`SessionDeletedEvent`); pattern-match on the variant class to
906+
branch on the event kind.
907+
"""
891908

892-
type: SessionLifecycleEventType
893909
sessionId: str
894910
metadata: SessionLifecycleEventMetadata | None = None
895911

896-
@staticmethod
897-
def from_dict(data: dict) -> SessionLifecycleEvent:
898-
metadata = None
899-
if "metadata" in data and data["metadata"]:
900-
metadata = SessionLifecycleEventMetadata.from_dict(data["metadata"])
901-
return SessionLifecycleEvent(
902-
type=data.get("type", "session.updated"),
903-
sessionId=data.get("sessionId", ""),
904-
metadata=metadata,
905-
)
912+
913+
@dataclass
914+
class SessionCreatedEvent(SessionLifecycleEventBase):
915+
"""Emitted when a session is created."""
916+
917+
type: ClassVar[Literal["session.created"]] = "session.created"
918+
919+
920+
@dataclass
921+
class SessionDeletedEvent(SessionLifecycleEventBase):
922+
"""Emitted when a session is deleted."""
923+
924+
type: ClassVar[Literal["session.deleted"]] = "session.deleted"
925+
926+
927+
@dataclass
928+
class SessionUpdatedEvent(SessionLifecycleEventBase):
929+
"""Emitted when a session is updated (summary/title/etc. changed)."""
930+
931+
type: ClassVar[Literal["session.updated"]] = "session.updated"
932+
933+
934+
@dataclass
935+
class SessionForegroundEvent(SessionLifecycleEventBase):
936+
"""Emitted when a session moves to the foreground (TUI+server mode)."""
937+
938+
type: ClassVar[Literal["session.foreground"]] = "session.foreground"
939+
940+
941+
@dataclass
942+
class SessionBackgroundEvent(SessionLifecycleEventBase):
943+
"""Emitted when a session moves to the background (TUI+server mode)."""
944+
945+
type: ClassVar[Literal["session.background"]] = "session.background"
946+
947+
948+
SessionLifecycleEvent = (
949+
SessionCreatedEvent
950+
| SessionDeletedEvent
951+
| SessionUpdatedEvent
952+
| SessionForegroundEvent
953+
| SessionBackgroundEvent
954+
)
955+
956+
957+
def _session_lifecycle_event_from_dict(data: dict) -> SessionLifecycleEvent:
958+
"""Construct the correct :class:`SessionLifecycleEvent` variant from a wire dict."""
959+
metadata = None
960+
if "metadata" in data and data["metadata"]:
961+
metadata = SessionLifecycleEventMetadata.from_dict(data["metadata"])
962+
session_id = data.get("sessionId", "")
963+
match data.get("type"):
964+
case "session.created":
965+
return SessionCreatedEvent(sessionId=session_id, metadata=metadata)
966+
case "session.deleted":
967+
return SessionDeletedEvent(sessionId=session_id, metadata=metadata)
968+
case "session.foreground":
969+
return SessionForegroundEvent(sessionId=session_id, metadata=metadata)
970+
case "session.background":
971+
return SessionBackgroundEvent(sessionId=session_id, metadata=metadata)
972+
case _:
973+
# Default to ``session.updated`` for unknown event types so consumers
974+
# keep working across server upgrades.
975+
return SessionUpdatedEvent(sessionId=session_id, metadata=metadata)
906976

907977

908978
SessionLifecycleHandler = Callable[[SessionLifecycleEvent], None]
@@ -2922,7 +2992,7 @@ def handle_notification(method: str, params: dict):
29222992
session._dispatch_event(event)
29232993
elif method == "session.lifecycle":
29242994
# Handle session lifecycle events
2925-
lifecycle_event = SessionLifecycleEvent.from_dict(params)
2995+
lifecycle_event = _session_lifecycle_event_from_dict(params)
29262996
self._dispatch_lifecycle_event(lifecycle_event)
29272997

29282998
self._client.set_notification_handler(handle_notification)
@@ -3047,7 +3117,7 @@ def handle_notification(method: str, params: dict):
30473117
session._dispatch_event(event)
30483118
elif method == "session.lifecycle":
30493119
# Handle session lifecycle events
3050-
lifecycle_event = SessionLifecycleEvent.from_dict(params)
3120+
lifecycle_event = _session_lifecycle_event_from_dict(params)
30513121
self._dispatch_lifecycle_event(lifecycle_event)
30523122

30533123
self._client.set_notification_handler(handle_notification)

python/copilot/session.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import time
1919
from collections.abc import Awaitable, Callable
2020
from dataclasses import dataclass
21+
from datetime import UTC, datetime
2122
from types import TracebackType
2223
from typing import TYPE_CHECKING, Any, Literal, NotRequired, Required, TypedDict, cast
2324

@@ -627,7 +628,7 @@ class PreToolUseHookInput(TypedDict):
627628
"""Input for pre-tool-use hook"""
628629

629630
sessionId: str
630-
timestamp: int
631+
timestamp: datetime
631632
workingDirectory: str
632633
toolName: str
633634
toolArgs: Any
@@ -653,7 +654,7 @@ class PreMcpToolCallHookInput(TypedDict):
653654
"""Input for pre-MCP-tool-call hook"""
654655

655656
sessionId: str
656-
timestamp: int
657+
timestamp: datetime
657658
workingDirectory: str
658659
serverName: str
659660
toolName: str
@@ -684,7 +685,7 @@ class PostToolUseHookInput(TypedDict):
684685
"""Input for post-tool-use hook"""
685686

686687
sessionId: str
687-
timestamp: int
688+
timestamp: datetime
688689
workingDirectory: str
689690
toolName: str
690691
toolArgs: Any
@@ -709,7 +710,7 @@ class UserPromptSubmittedHookInput(TypedDict):
709710
"""Input for user-prompt-submitted hook"""
710711

711712
sessionId: str
712-
timestamp: int
713+
timestamp: datetime
713714
workingDirectory: str
714715
prompt: str
715716

@@ -732,7 +733,7 @@ class SessionStartHookInput(TypedDict):
732733
"""Input for session-start hook"""
733734

734735
sessionId: str
735-
timestamp: int
736+
timestamp: datetime
736737
workingDirectory: str
737738
source: Literal["startup", "resume", "new"]
738739
initialPrompt: NotRequired[str]
@@ -755,7 +756,7 @@ class SessionEndHookInput(TypedDict):
755756
"""Input for session-end hook"""
756757

757758
sessionId: str
758-
timestamp: int
759+
timestamp: datetime
759760
workingDirectory: str
760761
reason: Literal["complete", "error", "abort", "timeout", "user_exit"]
761762
finalMessage: NotRequired[str]
@@ -780,7 +781,7 @@ class ErrorOccurredHookInput(TypedDict):
780781
"""Input for error-occurred hook"""
781782

782783
sessionId: str
783-
timestamp: int
784+
timestamp: datetime
784785
workingDirectory: str
785786
error: str
786787
errorContext: Literal["model_call", "tool_execution", "system", "user_input"]
@@ -2229,9 +2230,18 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any:
22292230

22302231
try:
22312232
handler_start = time.perf_counter()
2232-
# Remap wire key "cwd" to public API key "workingDirectory"
2233-
if "cwd" in input_data:
2234-
input_data = {**input_data, "workingDirectory": input_data.pop("cwd")}
2233+
# Normalize input from the wire format:
2234+
# - Remap wire key "cwd" to public API key "workingDirectory".
2235+
# - Convert "timestamp" from epoch milliseconds to ``datetime`` so
2236+
# hook handlers see a timezone-aware ``datetime`` rather than a
2237+
# raw integer (matches TS PR #1357 Phase E).
2238+
transformed = dict(input_data)
2239+
if "cwd" in transformed:
2240+
transformed["workingDirectory"] = transformed.pop("cwd")
2241+
timestamp = transformed.get("timestamp")
2242+
if isinstance(timestamp, (int, float)):
2243+
transformed["timestamp"] = datetime.fromtimestamp(timestamp / 1000, tz=UTC)
2244+
input_data = transformed
22352245
result = handler(input_data, {"session_id": self.session_id})
22362246
if inspect.isawaitable(result):
22372247
result = await result

python/e2e/test_session_e2e.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import base64
44
import os
5+
from datetime import datetime
56

67
import pytest
78

@@ -306,8 +307,8 @@ async def test_should_list_sessions(self, ctx: E2ETestContext):
306307
assert hasattr(session_data, "isRemote")
307308
# summary is optional
308309
assert isinstance(session_data.sessionId, str)
309-
assert isinstance(session_data.startTime, str)
310-
assert isinstance(session_data.modifiedTime, str)
310+
assert isinstance(session_data.startTime, datetime)
311+
assert isinstance(session_data.modifiedTime, datetime)
311312
assert isinstance(session_data.isRemote, bool)
312313

313314
# Verify context field is present
@@ -365,8 +366,8 @@ async def test_should_get_session_metadata(self, ctx: E2ETestContext):
365366
metadata = await ctx.client.get_session_metadata(session.session_id)
366367
assert metadata is not None
367368
assert metadata.sessionId == session.session_id
368-
assert isinstance(metadata.startTime, str)
369-
assert isinstance(metadata.modifiedTime, str)
369+
assert isinstance(metadata.startTime, datetime)
370+
assert isinstance(metadata.modifiedTime, datetime)
370371
assert isinstance(metadata.isRemote, bool)
371372

372373
# Verify context field is present
@@ -854,8 +855,8 @@ async def test_should_get_session_metadata_by_id(self, ctx: E2ETestContext):
854855
await asyncio.sleep(0.1)
855856
assert metadata is not None
856857
assert metadata.sessionId == session.session_id
857-
assert isinstance(metadata.startTime, str) and metadata.startTime
858-
assert isinstance(metadata.modifiedTime, str) and metadata.modifiedTime
858+
assert isinstance(metadata.startTime, datetime)
859+
assert isinstance(metadata.modifiedTime, datetime)
859860

860861
not_found = await ctx.client.get_session_metadata("non-existent-session-id")
861862
assert not_found is None

0 commit comments

Comments
 (0)