-
Notifications
You must be signed in to change notification settings - Fork 1
test(plan): port 19 [post with Plan] tests (#55) #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e2fed25
test(plan): port 19 [post with Plan] tests from upstream
patrick-chinchill 4ef0b9f
fix(plan): close 3 upstream-parity gaps surfaced by #55 tests
patrick-chinchill 14fe9fe
fix(plan): narrow BaseException → Exception + minor test cleanup
patrick-chinchill cb4af3e
test(plan): port final [post with Plan] test — currentTask post-complete
patrick-chinchill b3fe670
fix(plan): narrow contextlib.suppress(BaseException) → Exception for …
patrick-chinchill File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import contextlib | ||
| import uuid | ||
| from dataclasses import dataclass, field | ||
| from typing import Any, Literal | ||
|
|
@@ -72,8 +73,17 @@ class AddTaskOptions: | |
|
|
||
| @dataclass | ||
| class UpdateTaskInput: | ||
| """Structured update input with optional output and status override.""" | ||
| """Structured update input targeting a task by ``id`` (or the last | ||
| in-progress task when ``id`` is omitted) with optional output and | ||
| status override. | ||
|
|
||
| Mirrors upstream ``UpdateTaskInput`` shape (`plan.ts`): | ||
| ``{ id?: string; output?: PlanContent; status?: PlanTaskStatus }``. | ||
| When ``id`` is set but no matching task exists, ``update_task`` | ||
| returns ``None`` (matching upstream). | ||
| """ | ||
|
|
||
| id: str | None = None | ||
| output: PlanContent | None = None | ||
| status: PlanTaskStatus | None = None | ||
|
|
||
|
|
@@ -194,7 +204,10 @@ class _BoundState: | |
| message_id: str | ||
| thread_id: str | ||
| logger: Logger | None = None | ||
| update_chain: asyncio.Future[None] | None = None | ||
| # Tail of the synchronously-built edit chain. Each ``_enqueue_edit`` | ||
| # reads this, chains a new task after it, and assigns the new tail | ||
| # — all before yielding — so concurrent callers see FIFO ordering. | ||
| update_chain: asyncio.Task[None] | None = None | ||
|
|
||
|
|
||
| # ============================================================================= | ||
|
|
@@ -329,24 +342,38 @@ async def add_task(self, options: AddTaskOptions) -> PlanTask | None: | |
| return PlanTask(id=next_task.id, title=next_task.title, status=next_task.status) | ||
|
|
||
| async def update_task(self, update: PlanContent | UpdateTaskInput | None = None) -> PlanTask | None: | ||
| """Update the current in-progress task. | ||
| """Update a task on this plan. | ||
|
|
||
| ``update`` can be: | ||
| - ``PlanContent`` (str, list, dict) -- sets the task output | ||
| - ``UpdateTaskInput`` -- sets output and/or status | ||
| - ``None`` -- just triggers a re-render | ||
| - ``PlanContent`` (str, list, dict) -- sets the output on the last | ||
| in-progress task (falling back to the last task). | ||
| - ``UpdateTaskInput`` -- sets output and/or status. When | ||
| ``update.id`` is set, targets that specific task and returns | ||
| ``None`` if no task matches. When ``id`` is omitted, behaves | ||
| like the PlanContent path (last in-progress task). | ||
| - ``None`` -- just triggers a re-render of the current state. | ||
| """ | ||
| if not self._can_mutate(): | ||
| return None | ||
| current: PlanModelTask | None = None | ||
| for t in reversed(self._model.tasks): | ||
| if t.status == "in_progress": | ||
| current = t | ||
| break | ||
| if current is None and self._model.tasks: | ||
| current = self._model.tasks[-1] | ||
| if current is None: | ||
| return None | ||
| if isinstance(update, UpdateTaskInput) and update.id is not None: | ||
| for t in self._model.tasks: | ||
| if t.id == update.id: | ||
| current = t | ||
| break | ||
| # Upstream returns null for a non-existent id rather than | ||
| # silently falling back to "last in-progress". | ||
| if current is None: | ||
| return None | ||
| else: | ||
| for t in reversed(self._model.tasks): | ||
| if t.status == "in_progress": | ||
| current = t | ||
| break | ||
| if current is None and self._model.tasks: | ||
| current = self._model.tasks[-1] | ||
| if current is None: | ||
| return None | ||
|
|
||
| if update is not None: | ||
| if isinstance(update, UpdateTaskInput): | ||
|
|
@@ -396,12 +423,27 @@ def _can_mutate(self) -> bool: | |
| async def _enqueue_edit(self) -> None: | ||
| """Edit the posted message with the current plan state. | ||
|
|
||
| Chains edits sequentially to avoid race conditions. | ||
| Chains edits sequentially to avoid race conditions. Mirrors the | ||
| upstream TS pattern (`plan.ts`): | ||
|
|
||
| ```ts | ||
| const chained = bound.updateChain.then(doEdit, doEdit); | ||
| bound.updateChain = chained.then(() => undefined, (err) => log); | ||
| return chained; | ||
| ``` | ||
|
|
||
| Crucially, the new chain tail (``update_chain``) is registered | ||
| **synchronously** — before any ``await`` — so that concurrent | ||
| callers racing through ``asyncio.gather`` observe a strict FIFO | ||
| ordering. Errors from the adapter edit propagate to the caller | ||
| via the returned awaitable (``chained``); the internal chain | ||
| absorbs them so the next enqueued edit still runs. | ||
| """ | ||
| if self._bound is None: | ||
| return | ||
|
|
||
| bound = self._bound | ||
| prev = bound.update_chain # synchronous read — must not await first | ||
|
|
||
| async def _do_edit() -> None: | ||
| if bound.fallback: | ||
|
|
@@ -421,17 +463,40 @@ async def _do_edit() -> None: | |
| self._model, | ||
| ) | ||
|
|
||
| # Chain edits: wait for previous edit to finish before starting new one | ||
| if bound.update_chain is not None: | ||
| async def _run_after_prev() -> None: | ||
| if prev is not None: | ||
| # Upstream ``.then(doEdit, doEdit)`` runs doEdit whether | ||
| # the previous edit resolved or rejected; mirror that by | ||
| # absorbing any exception from the previous step here. | ||
| # (Note: the internal chain tail absorbs errors anyway, | ||
| # so in practice ``await prev`` only raises if someone | ||
| # swapped the chain out with a rejecting future — the | ||
| # suppression keeps the parity guarantee defensive.) | ||
| with contextlib.suppress(Exception): | ||
| await prev | ||
| await _do_edit() | ||
|
|
||
| loop = asyncio.get_running_loop() | ||
| chained = loop.create_task(_run_after_prev()) | ||
|
|
||
| async def _absorb_for_chain() -> None: | ||
| # The internal chain tail must not propagate errors — otherwise | ||
| # the next enqueued edit would await a rejected future and be | ||
| # treated as a previous-failure chain that still runs doEdit, | ||
| # but we'd also lose the ability to recover cleanly. Upstream | ||
| # uses ``chained.then(() => undefined, (err) => logger.warn)``. | ||
| # | ||
| # Catch ``Exception`` (not ``BaseException``) so that | ||
| # ``asyncio.CancelledError``, ``KeyboardInterrupt``, and | ||
| # ``SystemExit`` propagate — only regular failures need to be | ||
| # absorbed here so the next enqueued edit can still run. | ||
| try: | ||
| await bound.update_chain | ||
| except Exception as prev_exc: | ||
| if bound.logger: | ||
| bound.logger.warn("Previous plan edit failed", prev_exc) | ||
|
|
||
| try: | ||
| bound.update_chain = asyncio.get_running_loop().create_task(_do_edit()) | ||
| await bound.update_chain | ||
| except Exception as exc: | ||
| if bound.logger: | ||
| bound.logger.warn("Failed to edit plan", exc) | ||
| await chained | ||
| except Exception as exc: # noqa: BLE001 — log and swallow for queue | ||
| if bound.logger is not None: | ||
| bound.logger.warn("Failed to edit plan", exc) | ||
|
|
||
| bound.update_chain = loop.create_task(_absorb_for_chain()) | ||
| # ``chained`` preserves upstream semantics: exceptions from the | ||
| # adapter edit propagate to the caller. | ||
| await chained | ||
|
github-code-quality[bot] marked this conversation as resolved.
Fixed
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. False positive — same as #502 above; |
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
False positive —
await prevundercontextlib.suppress(...)has real side effects (drives the previous coroutine to completion).