Skip to content

Commit 6ada545

Browse files
fix(plan): close 3 upstream-parity gaps surfaced by #55 tests
- update_task now honors input.id (was only last in-progress) - add_task/update_task propagate adapter.edit_object errors - _enqueue_edit is actually sequential under asyncio.gather Un-skips the 5 previously-skipped tests in TestPostWithPlan. Closes the remaining parity gaps in #55. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1826a71 commit 6ada545

4 files changed

Lines changed: 245 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
- Ported 19 `[post with Plan]` tests from `thread.test.ts` — closes #55.
88

9+
### Fixes (parity with upstream Plan semantics)
10+
11+
- **`Plan.update_task(input)` / `StreamingPlan.update_task(input)` now honor `input.id`** — previously only worked on the last in-progress task; with `id` set, targets that specific task and returns `None` for unknown IDs. Matches upstream `UpdateTaskInput` semantics.
12+
- **`Plan.add_task()` / `update_task()` now propagate `adapter.edit_object` errors** — previously swallowed and logged; upstream returns the chained promise so callers see failures.
13+
- **Plan edit queue is now actually sequential under concurrency** — previously racy under `asyncio.gather`; rewrote `_enqueue_edit` to build the chain synchronously before awaiting, matching upstream TS's `.then`-based chain. Fixes out-of-order edits when multiple `add_task`/`update_task` calls interleave.
14+
915
## 0.4.26.1 (2026-04-23)
1016

1117
Python-only follow-up on `0.4.26`. Still alpha — APIs may change.

src/chat_sdk/plan.py

Lines changed: 88 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
import asyncio
12+
import contextlib
1213
import uuid
1314
from dataclasses import dataclass, field
1415
from typing import Any, Literal
@@ -72,8 +73,17 @@ class AddTaskOptions:
7273

7374
@dataclass
7475
class UpdateTaskInput:
75-
"""Structured update input with optional output and status override."""
76+
"""Structured update input targeting a task by ``id`` (or the last
77+
in-progress task when ``id`` is omitted) with optional output and
78+
status override.
79+
80+
Mirrors upstream ``UpdateTaskInput`` shape (`plan.ts`):
81+
``{ id?: string; output?: PlanContent; status?: PlanTaskStatus }``.
82+
When ``id`` is set but no matching task exists, ``update_task``
83+
returns ``None`` (matching upstream).
84+
"""
7685

86+
id: str | None = None
7787
output: PlanContent | None = None
7888
status: PlanTaskStatus | None = None
7989

@@ -194,7 +204,10 @@ class _BoundState:
194204
message_id: str
195205
thread_id: str
196206
logger: Logger | None = None
197-
update_chain: asyncio.Future[None] | None = None
207+
# Tail of the synchronously-built edit chain. Each ``_enqueue_edit``
208+
# reads this, chains a new task after it, and assigns the new tail
209+
# — all before yielding — so concurrent callers see FIFO ordering.
210+
update_chain: asyncio.Task[None] | None = None
198211

199212

200213
# =============================================================================
@@ -329,24 +342,38 @@ async def add_task(self, options: AddTaskOptions) -> PlanTask | None:
329342
return PlanTask(id=next_task.id, title=next_task.title, status=next_task.status)
330343

331344
async def update_task(self, update: PlanContent | UpdateTaskInput | None = None) -> PlanTask | None:
332-
"""Update the current in-progress task.
345+
"""Update a task on this plan.
333346
334347
``update`` can be:
335-
- ``PlanContent`` (str, list, dict) -- sets the task output
336-
- ``UpdateTaskInput`` -- sets output and/or status
337-
- ``None`` -- just triggers a re-render
348+
- ``PlanContent`` (str, list, dict) -- sets the output on the last
349+
in-progress task (falling back to the last task).
350+
- ``UpdateTaskInput`` -- sets output and/or status. When
351+
``update.id`` is set, targets that specific task and returns
352+
``None`` if no task matches. When ``id`` is omitted, behaves
353+
like the PlanContent path (last in-progress task).
354+
- ``None`` -- just triggers a re-render of the current state.
338355
"""
339356
if not self._can_mutate():
340357
return None
341358
current: PlanModelTask | None = None
342-
for t in reversed(self._model.tasks):
343-
if t.status == "in_progress":
344-
current = t
345-
break
346-
if current is None and self._model.tasks:
347-
current = self._model.tasks[-1]
348-
if current is None:
349-
return None
359+
if isinstance(update, UpdateTaskInput) and update.id is not None:
360+
for t in self._model.tasks:
361+
if t.id == update.id:
362+
current = t
363+
break
364+
# Upstream returns null for a non-existent id rather than
365+
# silently falling back to "last in-progress".
366+
if current is None:
367+
return None
368+
else:
369+
for t in reversed(self._model.tasks):
370+
if t.status == "in_progress":
371+
current = t
372+
break
373+
if current is None and self._model.tasks:
374+
current = self._model.tasks[-1]
375+
if current is None:
376+
return None
350377

351378
if update is not None:
352379
if isinstance(update, UpdateTaskInput):
@@ -396,12 +423,27 @@ def _can_mutate(self) -> bool:
396423
async def _enqueue_edit(self) -> None:
397424
"""Edit the posted message with the current plan state.
398425
399-
Chains edits sequentially to avoid race conditions.
426+
Chains edits sequentially to avoid race conditions. Mirrors the
427+
upstream TS pattern (`plan.ts`):
428+
429+
```ts
430+
const chained = bound.updateChain.then(doEdit, doEdit);
431+
bound.updateChain = chained.then(() => undefined, (err) => log);
432+
return chained;
433+
```
434+
435+
Crucially, the new chain tail (``update_chain``) is registered
436+
**synchronously** — before any ``await`` — so that concurrent
437+
callers racing through ``asyncio.gather`` observe a strict FIFO
438+
ordering. Errors from the adapter edit propagate to the caller
439+
via the returned awaitable (``chained``); the internal chain
440+
absorbs them so the next enqueued edit still runs.
400441
"""
401442
if self._bound is None:
402443
return
403444

404445
bound = self._bound
446+
prev = bound.update_chain # synchronous read — must not await first
405447

406448
async def _do_edit() -> None:
407449
if bound.fallback:
@@ -421,17 +463,35 @@ async def _do_edit() -> None:
421463
self._model,
422464
)
423465

424-
# Chain edits: wait for previous edit to finish before starting new one
425-
if bound.update_chain is not None:
466+
async def _run_after_prev() -> None:
467+
if prev is not None:
468+
# Upstream ``.then(doEdit, doEdit)`` runs doEdit whether
469+
# the previous edit resolved or rejected; mirror that by
470+
# absorbing any exception from the previous step here.
471+
# (Note: the internal chain tail absorbs errors anyway,
472+
# so in practice ``await prev`` only raises if someone
473+
# swapped the chain out with a rejecting future — the
474+
# suppression keeps the parity guarantee defensive.)
475+
with contextlib.suppress(BaseException):
476+
await prev
477+
await _do_edit()
478+
479+
loop = asyncio.get_running_loop()
480+
chained = loop.create_task(_run_after_prev())
481+
482+
async def _absorb_for_chain() -> None:
483+
# The internal chain tail must not propagate errors — otherwise
484+
# the next enqueued edit would await a rejected future and be
485+
# treated as a previous-failure chain that still runs doEdit,
486+
# but we'd also lose the ability to recover cleanly. Upstream
487+
# uses ``chained.then(() => undefined, (err) => logger.warn)``.
426488
try:
427-
await bound.update_chain
428-
except Exception as prev_exc:
429-
if bound.logger:
430-
bound.logger.warn("Previous plan edit failed", prev_exc)
431-
432-
try:
433-
bound.update_chain = asyncio.get_running_loop().create_task(_do_edit())
434-
await bound.update_chain
435-
except Exception as exc:
436-
if bound.logger:
437-
bound.logger.warn("Failed to edit plan", exc)
489+
await chained
490+
except BaseException as exc: # noqa: BLE001 — log and swallow for queue
491+
if bound.logger is not None:
492+
bound.logger.warn("Failed to edit plan", exc)
493+
494+
bound.update_chain = loop.create_task(_absorb_for_chain())
495+
# ``chained`` preserves upstream semantics: exceptions from the
496+
# adapter edit propagate to the caller.
497+
await chained

tests/test_plan.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,13 @@ def error(self, message: str, *args: Any) -> None:
543543

544544
class TestEditErrorPath:
545545
@pytest.mark.asyncio
546-
async def test_edit_failure_is_logged_and_plan_continues(self) -> None:
547-
"""When adapter.edit_message raises, the error is logged and
548-
the next mutation still fires successfully."""
546+
async def test_edit_failure_propagates_and_plan_continues(self) -> None:
547+
"""When ``adapter.edit_message`` raises, the caller sees the
548+
exception (mirroring upstream TS ``enqueueEdit``), the failure
549+
is logged by the internal chain tail, and the next mutation
550+
still fires successfully without the previous rejection
551+
poisoning the queue.
552+
"""
549553
adapter = _FailingEditAdapter()
550554
logger = _SpyLogger()
551555
thread = _make_thread(adapter=adapter)
@@ -556,20 +560,19 @@ async def test_edit_failure_is_logged_and_plan_continues(self) -> None:
556560
assert plan._bound is not None
557561
plan._bound.logger = logger
558562

559-
# First mutation: edit will fail
563+
# First mutation: edit will fail — caller observes the error.
560564
adapter.fail_edit = True
561-
await plan.add_task(AddTaskOptions(title="Step 2"))
565+
with pytest.raises(RuntimeError, match="simulated edit failure"):
566+
await plan.add_task(AddTaskOptions(title="Step 2"))
562567

563-
# The error should have been logged
568+
# The internal chain absorbs the error and logs it so the queue
569+
# is not poisoned for the next edit.
564570
assert any("Failed to edit plan" in w[0] for w in logger.warnings)
565571

566-
# Second mutation: edit succeeds -- plan is still usable
572+
# Second mutation: edit succeeds -- plan is still usable.
567573
adapter.fail_edit = False
568574
logger.warnings.clear()
569575
task = await plan.add_task(AddTaskOptions(title="Step 3"))
570576
assert task is not None
571577
assert task.title == "Step 3"
572578
assert len(plan.tasks) == 3
573-
574-
# The previous-chain failure should also be logged
575-
assert any("Previous plan edit failed" in w[0] for w in logger.warnings)

0 commit comments

Comments
 (0)