Skip to content

feat(acp): close v2 draft DTO gaps - #160

Merged
YoungSx merged 7 commits into
developfrom
chore/acp-v2-gap-ledger-149
Sep 2, 2026
Merged

feat(acp): close v2 draft DTO gaps#160
YoungSx merged 7 commits into
developfrom
chore/acp-v2-gap-ledger-149

Conversation

@YoungSx

@YoungSx YoungSx commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes the ACP v2 draft wire-model gaps tracked in #149. The live runtime stays intentionally V1-only.

  • Renames the modeled-version ceiling to HighestModeled and adds RuntimeServed to separate "the SDK models this version's wire contracts" from "a live client will run it". Latest stays as an [Obsolete] alias because it shipped in the published SalmonEgg.Acp 1.0.0 surface and ApiCompat forbids removing it.
  • Fixes a real negotiation defect: the post-negotiation check asked IsSupported, which is true for draft v2, so an Agent could downgrade the connection onto a version whose lifecycle is unimplemented.
  • Models the v2 shapes: state_update (running/idle/requires_action), whole-message upserts alongside the existing *_chunk family, Agent-owned terminal notifications, streaming tool-call content, structured diffs, permission subjects, object-marker capabilities, configId, icons, opaque session cursors, text command input, and plan envelopes.
  • Makes a v2 session/prompt response a bare acknowledgement. HasStopReason distinguishes an empty v2 ack from a v1 terminal result, so an ack can no longer fabricate end_turn.
  • v2-only writes fail closed under a v1 write context wherever a shared discriminator could otherwise put v2 fields on a v1 wire.

Validation

  • ACP_PACKAGE_BASELINE_VERSION=1.0.0 scripts/gates/run-acp-sdk-gates.sh Debug: formatting, analyzer builds, 399 contract tests, pack, and ApiCompat against the published 1.0.0 baseline all pass.
  • Downstream consumers compile: SalmonEgg.Application and SalmonEgg.Presentation.Core both 0 errors. The ACP SDK gates do not build consumers, so this is checked separately — an earlier revision of this work named a type SessionState, which collided with SalmonEgg.Domain.Models.Session.SessionState and broke ChatService; it is now SessionWorkState.
  • Rebuilt on top of develop immediately before opening this PR.

Not in scope

Verification caveat

v2 live-lifecycle E2E is not achievable today. Local real agents codex-acp 0.0.46 and claude-agent-acp 0.48.0 both answer protocolVersion: 1 when asked for v2, and return pure v1 capability shapes. The strongest available validation is wire-level v1/v2 dual-line assertions plus reverse verification; "v2 live connection semantics" remains unverified and is documented as such on #149.

Refs #149.

🤖 Generated with Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
salmonegg Ready Ready Preview Sep 2, 2026 12:34pm UTC

YoungSx and others added 7 commits September 2, 2026 12:06
AcpProtocolVersion.Latest reads as "the version to use", but its value is
only the highest version whose wire contracts are modeled - and that version
is a draft the live client refuses to negotiate. The doc comment said so; the
name contradicted it.

Latest shipped in SalmonEgg.Acp 1.0.0, so package validation forbids removing
it: it stays as an obsolete alias pointing at the new name. The deprecation
message is an internal constant so the contract can be asserted without
duplicating the string, and the test reads the field through reflection so
asserting on a deprecated member does not trip warnings-as-errors.

Refs #149.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ACP negotiates one version per connection and both sides then act
according to its specification, so a client is compliant serving exactly
one version - it does not have to branch every method on a version. What
it must do is close the connection when the Agent answers with a version
it cannot run.

The post-negotiation check asked IsSupported, which is true for draft v2
because the SDK models those wire contracts so they can be developed and
asserted. Modeling is not serving: parsing v2 says nothing about having
implemented its prompt lifecycle. Name that distinction as
AcpProtocolVersion.RuntimeServed and have both the initialize gate and
the negotiation check answer to it, instead of spelling out "!= V1" at
each site.

Today the two predicates only diverge above the served version, which
the existing serverProtocolIsNewer test already reaches - a probe throw
confirmed that, and confirmed a downgrade test written from the served
side cannot reach the check at all, so no such test is added. That test
now also pins why v2 is refused: the runtime does not serve it, not that
the SDK cannot parse it.

Refs #149.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
In v2 the session/prompt response is a bare acknowledgement and a turn
ends when the Agent reports idle carrying a stop reason, so state_update
is the wire shape the whole v2 prompt lifecycle rests on. Model it as an
open hierarchy: the schema's trailing unconstrained member makes any
state string valid, and unknown values that do not begin with _ are
reserved for future ACP, so they round-trip verbatim instead of being
downgraded here.

The wire form is doubly flattened - "state" and its payload are siblings
of the outer "sessionUpdate" discriminator. STJ resolves one
discriminator per hierarchy and rejects a JsonConverter on a polymorphic
derived type, so this variant is read and written by the containing
params converter rather than a JsonDerivedType registration.

Writing is fail-closed on the negotiated version, matching
SessionReplayFrom: state_update does not exist in v1, so emitting one
under a v1 write context would put a field on the wire a v1 Agent has no
contract for. Reading stays version-agnostic, because gating reads would
make the client the arbiter of the Agent's semantics.

stopReason degrades to "no reason reported" on a malformed value, per its
x-deserialize-default-on-error marking: losing the reason is recoverable,
dropping the end-of-turn signal is not. Omitted and null are likewise
kept distinct from end_turn, which would fabricate a completed turn out
of a bare idle transition.

Refs #149.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
v2 adds agent_message, user_message, and agent_thought, which carry a
complete message rather than one streamed fragment. They do not replace
the *_chunk updates: v2 keeps both families, so an Agent may stream
fragments, send whole messages, or mix the two in one session. These are
therefore new variants alongside the chunk ones, not a migration of them.

content is three-state and each state is a distinct instruction: absent
leaves the message unchanged, null clears it, and an array replaces the
whole array including content accumulated from earlier chunks with the
same message id. A plain nullable field cannot express that, so presence
is reported separately by HasContent. JsonIgnore keeps that flag off the
wire, which also means STJ never populates it - the params converter does,
because without it every update would read as "content absent" and a
clear could not be told from a no-op.

user_message matters beyond symmetry: in v2 the Agent must report where
the user's prompt landed in session history after accepting it, and that
update is the source of truth for the agent-owned message id.

Refs #149.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…streaming tool content

Three v2 shapes that all concern how the Agent reports work in progress.

Terminals: v2 has no terminal/* methods at all - the Client no longer
creates, waits on, kills, or releases terminals over ACP. The Agent owns
them and reports state through terminal_update and terminal_output_chunk,
so these two notifications replace the entire v1 client-implemented
terminal surface. The v1 request types stay on the public surface because
ApiCompat forbids removing them, so a test asserts the split rather than
inferring it from absence. Output chunks are independently base64-encoded,
which the tests pin by proving that concatenating the base64 text gives a
different result than decoding per chunk.

Structured diff: replaces v1's flat path/oldText/newText, which could only
describe one modified text file. Both shapes use the same "diff"
discriminator, so they are told apart by structure - a changes array means
v2. Malformed change items are skipped per x-deserialize-skip-invalid-items
rather than failing the whole diff alongside its valid siblings.

Streaming tool content: tool_call_content_chunk appends one item, unlike
tool_call_update's content array which replaces. Without it, streaming
tool output would mean resending everything produced so far on every
fragment.

Terminal patch fields get the same presence-flag treatment as whole
messages, for the same reason: absent means leave unchanged, null means
clear, and an empty exitStatus object still marks the terminal as exited.

Refs #149.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…nges

Complete the remaining draft v2 DTO surface that can be modeled without
a v2 Agent: permission subjects, configId, object-marker capabilities,
icons, opaque session cursors, text command input, plan_update content,
and v2's bare prompt acknowledgement.

V2 prompt responses no longer carry stopReason. The response converter
records whether a v1 stopReason was actually present, so an empty v2 ack
cannot silently fabricate an end_turn; its terminal reason instead comes
from idle state_update.

V2 config options rename id to configId. Reads accept both shapes while
writes select the negotiated wire field, keeping a v1 peer from seeing
v2-only fields and vice versa. Permission subject and plan-content unions
preserve unknown payloads verbatim for forward compatibility.

Refs #149.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… one

The v2 state_update payload was modeled as SalmonEgg.Acp.Protocol.SessionState,
which is an ambiguous reference wherever SalmonEgg.Domain.Models.Session is
imported alongside it. The chat layer does exactly that in 17 files, so
ChatService failed to compile with CS0104 and took the browserwasm head down
with it.

Renaming rather than aliasing at each use site: the type is new in this branch
and absent from the published 1.0.0 surface, so it is free to rename, and
"work state" is the more accurate name anyway. ACP's state_update reports
whether foreground work is running, idle, or blocked, while the domain enum is
a session lifecycle (active/waiting/cancelled/completed/error). The existing
SessionMode pair genuinely shares a concept and keeps its using-aliases; this
one never did.

The ACP SDK gates build only the SDK and its tests, so they were green while a
downstream consumer could not compile. Verified this time by compiling the
net10.0-browserwasm head that failed: 0 errors, 0 warnings, with only the local
emscripten native-link validation skipped.

Refs #149.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@YoungSx
YoungSx force-pushed the chore/acp-v2-gap-ledger-149 branch from 8454436 to f9607cc Compare September 2, 2026 12:27
@YoungSx
YoungSx merged commit 1ff78e9 into develop Sep 2, 2026
17 checks passed
@YoungSx
YoungSx deleted the chore/acp-v2-gap-ledger-149 branch September 2, 2026 12:59
YoungSx added a commit that referenced this pull request Sep 3, 2026
PR #160 modeled the v2 wire contracts and put 37 types on the public surface
with no compile-time signal at all. A 1.x consumer could build against a
protocol whose client lifecycle is unimplemented and which no live connection
negotiates, and find out only when initialize refuses the version. AGENTS.md
already forbade shipping draft wire from production entry points; nothing
enforced it past the SDK's own boundary.

Every one of those 37 types now carries [Experimental(SEACP002)], which the
compiler reports as an error by default rather than a warning. The id, the
diagnostic text and the documentation link are single-sourced in
AcpDraftProtocol because three places have to agree on them - the attributes,
the NoWarn entries, and the gates - and a literal at each site lets any one of
them drift silently. The default "for evaluation purposes only" wording is
replaced: it is true but omits the fact that decides whether to use the type
at all, and the CLI prints Message verbatim.

The two project-level NoWarn entries are forced, not chosen. The JSON source
generator emits AcpJsonContext.*.g.cs naming every registered draft type, and
a #pragma in a hand-written file cannot reach generated code - without the
suppression the SDK reports 615 errors and cannot compile its own contracts.
Verified by removing it. The cost is stated where it lands: the marker is also
silent inside AcpClient.cs, so the compiler no longer backstops the rule about
production entry points, and gate tests have to carry that weight instead.

The packaged README documents the id, both working suppressions, and the fact
that [SuppressMessage] is not one of them - SEACP002 comes from the compiler,
not an analyzer, so no attribute-based suppression applies at any category. It
also documents the channel the attribute cannot close: AcpJsonContext exposes
a JsonTypeInfo<T> per registered type without copying the attribute onto it,
so 26 draft contracts remain reachable without ever naming one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
YoungSx added a commit that referenced this pull request Sep 3, 2026
PR #160 modeled the v2 wire contracts and put 37 types on the public surface
with no compile-time signal at all. A 1.x consumer could build against a
protocol whose client lifecycle is unimplemented and which no live connection
negotiates, and find out only when initialize refuses the version. AGENTS.md
already forbade shipping draft wire from production entry points; nothing
enforced it past the SDK's own boundary.

Every one of those 37 types now carries [Experimental(SEACP002)], which the
compiler reports as an error by default rather than a warning. The id, the
diagnostic text and the documentation link are single-sourced in
AcpDraftProtocol because three places have to agree on them - the attributes,
the NoWarn entries, and the gates - and a literal at each site lets any one of
them drift silently. The default "for evaluation purposes only" wording is
replaced: it is true but omits the fact that decides whether to use the type
at all, and the CLI prints Message verbatim.

The two project-level NoWarn entries are forced, not chosen. The JSON source
generator emits AcpJsonContext.*.g.cs naming every registered draft type, and
a #pragma in a hand-written file cannot reach generated code - without the
suppression the SDK reports 615 errors and cannot compile its own contracts.
Verified by removing it. The cost is stated where it lands: the marker is also
silent inside AcpClient.cs, so the compiler no longer backstops the rule about
production entry points, and gate tests have to carry that weight instead.

The packaged README documents the id, both working suppressions, and the fact
that [SuppressMessage] is not one of them - SEACP002 comes from the compiler,
not an analyzer, so no attribute-based suppression applies at any category. It
also documents the channel the attribute cannot close: AcpJsonContext exposes
a JsonTypeInfo<T> per registered type without copying the attribute onto it,
so 26 draft contracts remain reachable without ever naming one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
YoungSx added a commit that referenced this pull request Sep 3, 2026
PR #160 modeled the v2 wire contracts and put 37 types on the public surface
with no compile-time signal at all. A 1.x consumer could build against a
protocol whose client lifecycle is unimplemented and which no live connection
negotiates, and find out only when initialize refuses the version. AGENTS.md
already forbade shipping draft wire from production entry points; nothing
enforced it past the SDK's own boundary.

Every one of those 37 types now carries [Experimental(SEACP002)], which the
compiler reports as an error by default rather than a warning. The id, the
diagnostic text and the documentation link are single-sourced in
AcpDraftProtocol because three places have to agree on them - the attributes,
the NoWarn entries, and the gates - and a literal at each site lets any one of
them drift silently. The default "for evaluation purposes only" wording is
replaced: it is true but omits the fact that decides whether to use the type
at all, and the CLI prints Message verbatim.

The two project-level NoWarn entries are forced, not chosen. The JSON source
generator emits AcpJsonContext.*.g.cs naming every registered draft type, and
a #pragma in a hand-written file cannot reach generated code - without the
suppression the SDK reports 615 errors and cannot compile its own contracts.
Verified by removing it. The cost is stated where it lands: the marker is also
silent inside AcpClient.cs, so the compiler no longer backstops the rule about
production entry points, and gate tests have to carry that weight instead.

The packaged README documents the id, both working suppressions, and the fact
that [SuppressMessage] is not one of them - SEACP002 comes from the compiler,
not an analyzer, so no attribute-based suppression applies at any category. It
also documents the channel the attribute cannot close: AcpJsonContext exposes
a JsonTypeInfo<T> per registered type without copying the attribute onto it,
so 26 draft contracts remain reachable without ever naming one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
YoungSx added a commit that referenced this pull request Sep 3, 2026
PR #160 modeled the v2 wire contracts and put 37 types on the public surface
with no compile-time signal at all. A 1.x consumer could build against a
protocol whose client lifecycle is unimplemented and which no live connection
negotiates, and find out only when initialize refuses the version. AGENTS.md
already forbade shipping draft wire from production entry points; nothing
enforced it past the SDK's own boundary.

Every one of those 37 types now carries [Experimental(SEACP002)], which the
compiler reports as an error by default rather than a warning. The id, the
diagnostic text and the documentation link are single-sourced in
AcpDraftProtocol because three places have to agree on them - the attributes,
the NoWarn entries, and the gates - and a literal at each site lets any one of
them drift silently. The default "for evaluation purposes only" wording is
replaced: it is true but omits the fact that decides whether to use the type
at all, and the CLI prints Message verbatim.

The two project-level NoWarn entries are forced, not chosen. The JSON source
generator emits AcpJsonContext.*.g.cs naming every registered draft type, and
a #pragma in a hand-written file cannot reach generated code - without the
suppression the SDK reports 615 errors and cannot compile its own contracts.
Verified by removing it. The cost is stated where it lands: the marker is also
silent inside AcpClient.cs, so the compiler no longer backstops the rule about
production entry points, and gate tests have to carry that weight instead.

The packaged README documents the id, both working suppressions, and the fact
that [SuppressMessage] is not one of them - SEACP002 comes from the compiler,
not an analyzer, so no attribute-based suppression applies at any category. It
also documents the channel the attribute cannot close: AcpJsonContext exposes
a JsonTypeInfo<T> per registered type without copying the attribute onto it,
so 26 draft contracts remain reachable without ever naming one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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.

1 participant