Skip to content

fix(client): a 304 confirms a payload rather than establishing one - #104

Merged
XieX merged 4 commits into
xie/agent-skills-feature-ac9ac7from
xie/skills-304-first-payload
Oct 5, 2026
Merged

XieX merged 4 commits into
xie/agent-skills-feature-ac9ac7from
xie/skills-304-first-payload

Conversation

@XieX

@XieX XieX commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes a path in the FDv2 delivery transport that could report a store holding nothing as initialized, which authorizes writeSkills('*') to prune every managed SKILL.md on disk. Found by reviewing a Bugbot comment on the Python side and checking whether this SDK shared the gap — it did, line for line, including the tests.

A poll adopted the response ETag after any body that did not throw, and a 304 published a first payload unconditionally. Between them, a body this SDK could not apply — a future intentCode, or a payload-transferred with no recognized intent — lent its etag to the next request, and the 304 that answered it reported the empty committed set as current: initialized, healthy, and with nothing in a 304 to notice it on.

Three claims narrowed:

  • payloadTransferred reports a commit only when it applied a pending set. A none intent builds none, nor does an unrecognized intentCode, and a foreign payload's contents are declined — those transfers now report nothing, where they reported a commit. They no longer adopt the selector of a payload that was never applied either, which on its own left a store resuming from content it does not hold while every diagnostic read healthy.
  • A poll adopts the response ETag only from a body that completed an exchange: a committed payload, or a none intent, which is the server saying the content held is what the etag describes. An unrecognized intent says the opposite — the body carried objects ignoreUnderUnknownIntent dropped — so leaving that poll unconditional is also what keeps the body arriving and its warning repeating, rather than silenced behind a 304. Gating on the commit alone would have broken the steady state, since the none body's etag is how a poll goes from a tiny 200 every interval to a bodiless 304.
  • A 304 no longer publishes a first payload. It confirms the payload the store holds; the exchange it stands in for, the none intent, does not publish one either.

Not a new rule: TESTING.md §3.25 already defines isInitialized() as true "once a payload has committed", and §3.21/§3.22 turn prune suppression on it.

The spec gains the bullets that were missing for the poll path in launchdarkly/ai-sdks-monorepo#34, and python-ai-sdk carries the identical fix in launchdarkly/python-ai-sdk#129 (where Bugbot raised it, on launchdarkly/python-ai-sdk#87).

Five tests had pinned the old behavior here, against Python's one. Three were only this side's optional changes / basis reading undefined where Python's dataclass defaults give [] / null; the new outcome states both, so the two languages return the same object. The other two were real, and the second is the clearest evidence this drifted rather than being designed:

  • releases waitForSkills on a 304 before any payload justified it as "a reconnect with a cached basis", which this transport has no mechanism for — basis and etag both start null with no injection point.
  • A JS-only lifecycle test asserted a 304 counts as initialized — the payload held is confirmed current, over a store holding no payload. It now exercises the 304 this store can actually reach, offering an etag it earned.

Test plan

  • yarn build && yarn test — full workspace green; client 1040 passed, 10 skipped
  • tsc --noEmit and biome check clean
  • New: a body under an unrecognized intent leaves the next request carrying no If-None-Match, the store uninitialized, nothing held, and no selector adopted
  • New: all four shapes that reach payloadTransferred with no pending set report neither a commit nor an up-to-date answer, and still count the transfer
  • Rewritten: a 304 answering a request that carried no etag leaves waitForSkills false and isInitialized() false without recording a failure

🤖 Generated with Claude Code


Note

Overview
Fixes FDv2 skill delivery so an empty store is never treated as initialized, which would let writeSkills('*') prune every managed SKILL.md on disk.

ProtocolReader.payloadTransferred now reports a commit only when a pending set was actually applied. Transfers that apply nothing—none intent, unknown intent codes, lone transfers, or declined foreign payloads—return no commit, no “up-to-date” signal on the transfer event, and do not adopt the payload selector as the resume basis.

Polling adopts the response ETag only after a body completes an exchange (a commit or a none intent on its own event). Bodies that announce transfers the SDK cannot apply no longer lend their etag to the next request.

HTTP 304 no longer calls markFirstPayload: it confirms content already held, it does not establish a first payload or release waitForSkills when nothing was ever committed.

packages/client/agents.md documents these three invariants. Tests are updated and expanded to pin the corrected behavior (including rejecting a standalone 304 before any payload and blocking etag/If-None-Match after unapplied bodies).

Reviewed by Cursor Bugbot for commit 44a3cf3. Bugbot is set up for automated code reviews on this repo. Configure here.

A poll adopted the response `ETag` after any body that did not throw, and a 304
published a first payload unconditionally. Between them, a body this SDK could
not apply lent its etag to the next request, and the 304 that answered it
reported the empty committed set as current: initialized, healthy, and with
nothing in a 304 to notice it on.

`isInitialized()` is what authorizes `writeSkills('*')` to prune, so that store
read as an environment whose every skill was revoked and deleted the last known
good `SKILL.md` files on disk — the widest version of the hazard the probe
exists to prevent.

Three claims narrowed to what actually happened:

- `payloadTransferred` reports a commit only when it applied a pending set. A
  `none` intent builds none, nor does an `intentCode` this SDK does not
  recognise, and a foreign payload's contents are declined — those transfers
  now report nothing, where they reported a commit. They no longer adopt the
  selector of a payload that was never applied either, which on its own left a
  store resuming from content it did not hold while every diagnostic read
  healthy.
- A poll adopts the response `ETag` only from a body that completed an
  exchange: a committed payload, or a `none` intent, which is the server saying
  the content held is what the etag describes. An unrecognised intent says the
  opposite — the body carried objects `ignoreUnderUnknownIntent` dropped — so
  leaving the poll unconditional is also what keeps that body arriving and its
  warning repeating, rather than silenced behind a 304.
- A 304 no longer publishes a first payload. It confirms the payload the store
  holds; the exchange it stands in for, the `none` intent, does not publish one
  either.

TESTING.md §3.25 defines `isInitialized()` as true "once a payload has
committed", and §3.21/§3.22 turn prune suppression on it, so none of this is a
new rule — it is the existing one reaching the poll path. Python carries the
same fix, and the two transports stay line-for-line on this.

Two tests pinned the old behaviour and now pin the corrected one. The polling
one justified it as "a reconnect with a cached basis", which this transport has
no mechanism for: `basis` and `etag` both start `null` with no injection point,
so a 304 reaching a store that holds nothing takes a server answering a request
that carried no etag at all. The lifecycle one asserted a 304 over an empty
store "counts as initialized — the payload held is confirmed current", with no
payload held; it now exercises the 304 the store can actually reach, offering
an etag it earned.

`changes: []` and `basis: null` are stated explicitly on the new outcome rather
than left off, because Python's dataclass defaults give exactly those and this
is the same outcome object on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three blocks the previous commit added were arguing the whole case inline —
the prune chain, the forward-compatibility reasoning, the cross-language note —
in 50-odd lines of comment on a 14-line change. This source gets read by
customers and their agents working out how to consume the SDK, and that much
nuance about internals they cannot reach is a wall to read past rather than
help.

Each block now states the rule and the one consequence that is visible from
outside: `is_initialized()` / `isInitialized()` goes true on a commit, and that
is what authorizes a prune of the files on disk. The long version already has
two homes it belongs in — `agents.md` in this package, and TESTING.md §3.25 —
so nothing is lost, and the clause that stops a plausible wrong fix ("not up to
date either: only the `none` intent says that") is kept.

Comments only; no behaviour change. Tests untouched, where the long-form
reasoning is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@XieX
XieX marked this pull request as ready for review October 2, 2026 15:00
XieX added a commit to launchdarkly/python-ai-sdk that referenced this pull request Oct 5, 2026
)

## Summary

Fixes a path in the FDv2 delivery transport that could report a store
holding **nothing** as initialized, which authorizes `write_skills("*")`
to prune every managed `SKILL.md` on disk. Raised by Bugbot on #87
(comment
[r4146430929](#87 (comment)));
verified reachable, and traced to two claims that were wider than what
happened.

A poll adopted the response `ETag` after any body that did not raise,
and a 304 published a first payload unconditionally. Between them, a
body this SDK could not apply — a future `intentCode`, or a
`payload-transferred` with no recognized intent — lent its etag to the
next request, and the 304 that answered it reported the empty committed
set as current: initialized, healthy, and with nothing in a 304 to
notice it on.

Three claims narrowed:

- `payload-transferred` reports a commit only when it applied a pending
set. A `none` intent builds none, nor does an unrecognized `intentCode`,
and a foreign payload's contents are declined — those transfers now
report nothing, where they reported a commit. They no longer adopt the
selector of a payload that was never applied either, which on its own
left a store resuming from content it does not hold while every
diagnostic read healthy.
- A poll adopts the response `ETag` only from a body that **completed an
exchange**: a committed payload, or a `none` intent, which is the server
saying the content held is what the etag describes. Gating on the commit
alone would have broken the steady state — the `none` body's etag is how
a poll goes from a tiny 200 every interval to a bodiless 304.
- A 304 no longer publishes a first payload. It confirms the payload the
store holds; the exchange it stands in for, the `none` intent, does not
publish one either.

Not a new rule: TESTING.md §3.25 already defines `is_initialized()` as
true "once a payload has committed", and §3.21/§3.22 turn prune
suppression on it. The spec gains the bullets that were missing for the
poll path in launchdarkly/ai-sdks-monorepo#34, and js-ai-sdk carries the
identical fix in launchdarkly/js-ai-sdk#104.

`test_a_304_before_any_payload_still_releases_wait_for_skills` justified
the old behavior as "a reconnect with a cached basis", which this
transport has no mechanism for — `_basis` and `_etag` both start as
`None` with no injection point — so a 304 reaching a store that holds
nothing takes a server answering a request that carried no etag at all.
It now fails closed, and the test asserts that.

## Test plan

- [x] `pytest` — 2164 passed, 11 skipped
- [x] `ruff check` / `ruff format --check` / `mypy packages/*/src` clean
- [x] New: a body under an unrecognized intent leaves the next request
carrying no `If-None-Match`, the store uninitialized, nothing held, and
no selector adopted
- [x] New: all four shapes that reach `payload-transferred` with no
pending set report neither a commit nor an up-to-date answer, and still
count the transfer
- [x] Rewritten: a 304 answering a request that carried no etag leaves
`wait_for_skills` false and `is_initialized()` false without recording a
failure
- [x] The over-cap poll test was leaning on the same path for its
"delivery carries on" assertion; its retry now gets a real payload

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Tightens FDv2 poll/stream semantics so an empty or unapplied store
cannot be treated as initialized and authorize `write_skills("*")` to
prune every managed skill on disk.
> 
> **`payload-transferred`** now reports a commit (and adopts a resume
`basis`) only when a pending set was actually applied. Transfers with
`none`, unknown intent codes, foreign payloads, or no pending work
return an empty outcome—still counted in diagnostics but not a commit or
up-to-date signal.
> 
> **Poll etag handling** adopts the response `ETag` only after a body
**completed an exchange** (committed payload or `none` on
`server-intent`). Unapplied bodies no longer lend their etag to a
follow-up **304**.
> 
> **HTTP 304** no longer calls `_publish_first_payload` or satisfies
`wait_for_skills`; it only confirms content already held after a real
commit.
> 
> Agent docs in `agents.md` spell out the three linked invariants. Tests
were added/rewritten for 304-before-payload, unrecognized-intent etag
chains, and non-committing transfers.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
daaf208. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

@jeffdupont jeffdupont left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving the fix. It matches python #129 line for line, and the spec's three 304 assertions (TESTING.md §3.25) are all here.

Before merge: this conflicts with the base now that #108 has landed (3 hunks in skills-fdv2.ts, 1 in agents.md), and the green CI is from before #108. I trial-merged 2709c66 into 43bfbc8, resolving every .ts hunk to this branch's side as python's daaf208c did. Dropping foreign || from basis is safe because the applied early return covers it. Result: vitest 1199 passed / 10 skipped, tsc and biome clean, #108's none-intent tests passing alongside. With the base's skills-fdv2.ts restored, 4 of the 5 new or rewritten tests fail. One trap: the 304 hunk's base side is this.markFirstPayload(), so taking it reinstates the bug.

One test gap, in both SDKs. §3.25 asks for all four no-commit shapes, and the loop here (and python's test_a_transfer_that_applied_nothing_is_not_a_commit) covers three. The declined foreign payload is missing. I put back the old foreign behaviour (applied without !foreign, basis keeping foreign ||), and the full suite still passed, because the foreign tests assert only basis. Could the foreign shape join the loop here, with a matching follow-up on the python side?

XieX and others added 2 commits October 5, 2026 14:36
…rst-payload

Conflicts were the base's comment and agents.md tightening against this
branch's 304/etag changes. Kept this branch's behaviour (no
markFirstPayload on a 304; withhold commit and selector when nothing
applied; adopt the etag only after a completed exchange) and the base's
trimmed prose. Since #108 objects after a `none` intent now build a
pending set, so the "applied" comment and the agents.md paragraph say
"a `none` intent no object followed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TESTING.md §3.25 asks for all four shapes that reach payload-transferred
with nothing to apply. The loop covered three; the declined foreign
payload was missing, so dropping `!foreign` from `applied` still passed
the suite, because the foreign tests assert only the basis. Add it,
priming the reader with a skill commit so the payload is recognisably
foreign, and assert the committed set is unchanged rather than empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@XieX
XieX merged commit 5ae1109 into xie/agent-skills-feature-ac9ac7 Oct 5, 2026
3 checks passed
@XieX
XieX deleted the xie/skills-304-first-payload branch October 5, 2026 18:52
XieX added a commit to launchdarkly/python-ai-sdk that referenced this pull request Oct 5, 2026
Follow-up to #129, and the Python half of the review comment on
launchdarkly/js-ai-sdk#104.

TESTING.md §3.25 says to assert all four shapes that reach
`payload-transferred` with nothing to apply: a `none` intent, an
unrecognised `intentCode`, a lone transfer with no intent, and a
declined foreign payload.
`test_a_transfer_that_applied_nothing_is_not_a_commit` covered three.
The foreign case was missing.

**Why it matters:** if `not foreign` is dropped from `applied` and the
old `basis` guard is put back, the full suite still passes, because the
foreign-payload tests check only the basis, `payloads_ignored` and the
held set. The outcome would report `committed=True`, which resets
`connection_failures` and lets a poll keep the etag for content it never
applied.

**Change:**
- The foreign shape joins the loop. A payload only counts as foreign
once a skill payload has committed, so each shape can now run an earlier
payload first.
- The test now checks that the committed set is unchanged and that
`payloads_transferred` went up by one, instead of checking for an empty
store and a count of 1.

**Verified:**
- The test passes on the current code.
- With the mutation above it fails.
- `make lint`, `format-check`, `typecheck` and `test` all pass (2200
passed).

The matching JS change is pushed to launchdarkly/js-ai-sdk#104.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Extends **`test_a_transfer_that_applied_nothing_is_not_a_commit`** so
it matches TESTING.md §3.25: the loop now covers all four ways a
transfer can finish with nothing applied, including a **declined foreign
payload** (`env-flags` after a real skill commit).
> 
> Each scenario can run an optional **`prior`** event sequence (needed
so “foreign” only applies once the skill payload is known). Assertions
were generalized to **`committed` / `up_to_date` / `basis` unchanged**,
**held objects unchanged** vs a snapshot, and **`payloads_transferred`
increments by one**—so a regression that wrongly sets `committed=True`
on ignored transfers would fail even when basis-only tests still pass.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4f672f6. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants