Repository navigation
feat: add support for plans and steps - #648
Lily Du (lilyydu) wants to merge 9 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Plan completion can bypass targeted-message privacy and accept conflicting control metadata instead of completing the Plan.
2 open issues
What changed in this PR
Adds prompt-bound Plans and progress steps to the Teams SDK, with completion through normal messages or the existing HTTP stream.
Changes:
- Introduces Plan models and the
ctx.plan()helper. - Adds control acknowledgment handling, completion routing, and stream timeout protection.
- Adds model, transport, and application tests plus a lifecycle example.
| File | Description |
|---|---|
uv.lock |
Registers the Plan example workspace package. |
README.md |
Links to the Plan example. |
packages/apps/tests/test_plan.py |
Tests helpers, prompt binding, and send routing. |
packages/apps/tests/test_plan_stream.py |
Tests streamed completion and timeout behavior. |
packages/apps/src/microsoft_teams/apps/routing/activity_context.py |
Adds cached, prompt-bound ctx.plan() helpers. |
packages/apps/src/microsoft_teams/apps/plan.py |
Implements Plan controls and completion. |
packages/apps/src/microsoft_teams/apps/http_stream.py |
Prevents Plan completion through the update fallback. |
packages/apps/src/microsoft_teams/apps/activity_send.py |
Routes Plan activities through creation rather than updates. |
packages/apps/src/microsoft_teams/apps/__init__.py |
Exports Plan. |
packages/api/tests/unit/test_plan_transport.py |
Tests acknowledgments, completion responses, and errors. |
packages/api/tests/unit/test_plan_models.py |
Tests model parsing and serialization. |
packages/api/src/microsoft_teams/api/models/entity/plan_info_entity.py |
Defines Plan and step models. |
packages/api/src/microsoft_teams/api/models/entity/entity.py |
Registers typed Plan entity parsing. |
packages/api/src/microsoft_teams/api/models/entity/__init__.py |
Exports Plan models and types. |
packages/api/src/microsoft_teams/api/clients/conversation/activity.py |
Handles Plan control acknowledgments. |
examples/plan/src/main.py |
Demonstrates completion, streaming, controls, and proactive Plans. |
examples/plan/README.md |
Documents example commands. |
examples/plan/pyproject.toml |
Defines example dependencies. |
Review effort: Balanced
| # module, so holding it here would be circular. | ||
| self._oauth_connection_names: List[str] = list(oauth_connection_names or []) | ||
| self._stream: Optional[StreamerProtocol] = None | ||
| self._plans: dict[str, Plan] = {} |
There was a problem hiding this comment.
Why is this a dict? Can’t you only have 1 Plan per activity?
|
|
||
|
|
||
| class PlanConflictStreamError(HTTPStatusError): | ||
| """A Plan conflict that must reach the caller without retry.""" |
There was a problem hiding this comment.
what does this error actually mean? can you expand the comment?
| create_lookup_step(), | ||
| Step( | ||
| id="answer", | ||
| version=1, |
There was a problem hiding this comment.
for “version” let’s include a comment to draw attention that it needs to be sequentially increasing
| ), | ||
| Step( | ||
| id="answer", | ||
| version=2, |
There was a problem hiding this comment.
wait DOES it need to be sequentially increasing? what does version actually mean?
| class StepSource(CustomBaseModel): | ||
| title: str | ||
| url: str | ||
| icon: Optional[str] = None |
There was a problem hiding this comment.
What format does icon accept—an emoji, an image URL, or a predefined icon name? Could we document the expected format with an example? If the service accepts a fixed set of names, a Literal or enum would make that contract clearer.
| if not isinstance(entity_type, str): | ||
| return "_unknown" | ||
|
|
||
| if entity_type == "planInfo": |
There was a problem hiding this comment.
Could we add "planInfo": "planInfo" to _KNOWN_ENTITY_TYPE_TAGS instead of adding a separate branch? It has a unique type string, so the existing lookup handles it without special-casing. The isinstance checks above are needed to distinguish models that share the same type string.
| Preview API. Diagnostic: ExperimentalTeamsPlan | ||
| """ | ||
|
|
||
| def __init__(self, api: ApiClient, conversation_ref: ConversationReference, *, reply_to_id: str) -> None: |
There was a problem hiding this comment.
Could we add a constructor docstring documenting each parameter (api, conversation_ref, and reply_to_id)? In particular, clarify which activity ID reply_to_id should refer to and how it relates to conversation_ref.activity_id. It would also help to mention that the conversation reference is copied and the API client is scoped to its service URL.
| await self._send_plan_update("error") | ||
|
|
||
| async def _send_plan_update(self, operation: PlanType, steps: Sequence[Step] | None = None) -> None: | ||
| entity = PlanInfoEntity(plan_type=operation, steps=list(steps) if steps is not None else None) |
There was a problem hiding this comment.
Could we add a debug log here identifying the plan operation and reply-to activity ID? That would help trace lifecycle sends when debugging without logging the step contents.
| self._reply_to_id = reply_to_id | ||
| self.events = EventEmitter[Literal["sent"]]() | ||
|
|
||
| async def start(self, steps: Sequence[Step]) -> None: |
There was a problem hiding this comment.
Could we add docstrings for every public member of Plan, including start(), update(), stop(), error(), and the events emitter? Please document parameters, behavior, and relevant return values or exceptions. For events, explain the sent payload and when it fires.
| self._reply_to_id = reply_to_id | ||
| self.events = EventEmitter[Literal["sent"]]() | ||
|
|
||
| async def start(self, steps: Sequence[Step]) -> None: |
There was a problem hiding this comment.
Could we also add this requirement to .github/copilot-instructions.md so future agent-generated changes include docstrings for public APIs by default? The instruction should cover public classes, constructors, methods, and exposed events, documenting their behavior, parameters, and relevant return values or exceptions.
| """ | ||
| activity = self._final_activity or MessageActivityInput() | ||
| if any(isinstance(entity, PlanInfoEntity) for entity in activity.entities or []): | ||
| raise StreamTimedOutError( |
There was a problem hiding this comment.
How should the Plan and streaming error contracts relate here? I understand why a Plan completion can't use the PUT fallback, but should callers receive StreamTimedOutError for that limitation, or a Plan-specific error? Could we clarify how callers should distinguish this case and recover?


Summary
Plan,Step,StepSource, andPlanInfoEntity, withctx.plan()for prompt-bound progress.