feat(bottom-panel): activate protocol v21 by frontend counter-offer

Bottom-panel Stage 2B-3, part 1 of 3: the compatibility-preserving v21
activation mechanism and the negotiated `panel_capable` flip.

2B-1 reserved the v21 wire and 2B-2 built the daemon projection behind
it, both dark, because the handshake is server-first: the daemon writes
`Hello` before the frontend has said anything, and a frontend rejects a
`protocol_version` outside its supported range *before* it can send
`AttachRequest`. Advertising 21 there is therefore an incompatible act on
its own, independent of whether one new message is ever exchanged.

So the advertised version does not move. `ADVERTISED_PROTOCOL_VERSION`
becomes a permanent compatibility BASELINE, and the session's real
version is settled one message later, by the frontend:

  1. the daemon advertises the baseline (20, unchanged);
  2. the frontend answers `requested_protocol_version(baseline)` — its
     own `PROTOCOL_VERSION` when the baseline is the current one, and a
     verbatim echo of anything older;
  3. the daemon records `negotiated_session_version(offer)`.

A shipped v20 frontend echoes 20 and gets a v20 session, byte-for-byte
as before — the real-daemon acceptance that emulates its rejection point
still passes untouched. A current frontend offers up and gets v21. The
`Hello` encoding and value are unchanged, which is why the old frontend
never sees a version it must reject.

`peer_declared_panel_support` gains the arm 2B-2 deliberately left off:
a semantic session is panel-capable exactly when it negotiated
`PANEL_MIN_VERSION` or later. The gate is on placement, not only
transport, so a v6-v20 semantic session keeps the Stage 1 fallback.

The GPU client's `server_protocol_version` splits into
`session_protocol_version` (what the session speaks — every wire gate
keys on this) and `baseline_protocol_version` (what `Hello` advertised).
They now differ in the normal case, and that difference IS the
compatibility property, so both headless probe reports emit both keys and
the two ratchets that read them assert both directions: session 21 AND
baseline 20. Asserting only the session version would pass if the
baseline had been bumped too — the exact incompatible change this
mechanism avoids.

Also fixes a pre-existing `unused_mut` in a `crdt`-gated daemon test,
dark to the standard clippy gate because that gate runs without the
feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-29 17:42:08 -04:00
parent e003b81cdd
commit d03845e826
8 changed files with 260 additions and 58 deletions

View File

@ -34,7 +34,7 @@ use pmacs_protocol::{
FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key,
KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS,
SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message,
write_message,
requested_protocol_version, write_message,
};
use winit::event_loop::EventLoopProxy;
@ -557,8 +557,16 @@ fn connect_stream_with_sink(
// AttachRequest — declare the capabilities a semantic frontend
// needs. `multi_frontend` is included because the existing daemon
// gates `crdt_replica` behind it (M10.x dependency).
// Bottom-panel Stage 2B-3: the `Hello` version is a compatibility
// BASELINE, and this counter-offer is what activates anything above
// it. The handshake is server-first, so the daemon cannot advertise a
// version a shipped frontend might reject; the frontend is the only
// party that can safely name a higher one, because by this point it
// has already accepted the baseline. `requested_protocol_version`
// echoes anything older than the current baseline verbatim.
let session_protocol_version = requested_protocol_version(hello.protocol_version);
let req = AttachRequest {
protocol_version: hello.protocol_version,
protocol_version: session_protocol_version,
frontend_capabilities: FrontendCapabilities {
synchronized_output: false,
unicode_smp: true,
@ -678,7 +686,8 @@ fn connect_stream_with_sink(
outbox,
shutdown_handle,
frontend_id: hello.assigned_frontend_id,
server_protocol_version: hello.protocol_version,
session_protocol_version,
baseline_protocol_version: hello.protocol_version,
initial_message,
})
}
@ -859,10 +868,25 @@ pub struct AttachClient {
/// `FrontendEvent` carries this so the daemon can route input back
/// to the per-session `SemanticRenderState`.
frontend_id: FrontendId,
/// The daemon's `Hello.protocol_version`. Wire variants newer
/// than the daemon (e.g. `Pointer`, v5) must be gated on this —
/// an older daemon hard-errors decoding an unknown variant.
server_protocol_version: u32,
/// The version this session actually speaks: the frontend's
/// `AttachRequest` counter-offer, which the daemon adopts. Wire
/// variants newer than the session (e.g. `Pointer`, v5) must be gated
/// on this — a daemon below the variant's floor hard-errors decoding
/// an unknown discriminant.
///
/// Bottom-panel Stage 2B-3: this is deliberately NOT
/// `Hello.protocol_version` any more. The server-first `Hello` carries
/// a compatibility *baseline* that no shipped frontend may be forced
/// to reject, so it under-reports what the pair can speak; the offer
/// this frontend made is the session's real ceiling.
session_protocol_version: u32,
/// The baseline the daemon advertised in its server-first `Hello`.
///
/// Kept beside the negotiated version because they answer different
/// questions, and because "the daemon still advertises 20 while this
/// session runs 21" is precisely the compatibility property Stage
/// 2B-3 has to be able to demonstrate.
baseline_protocol_version: u32,
/// Target snapshot retained across the pre-window readiness barrier.
initial_message: Option<InstanceMessage>,
}
@ -914,7 +938,7 @@ impl AttachClient {
/// Send a `FrontendEvent::Pointer` (session M-2): a locally
/// hit-tested gesture in source bytes. Callers gate on
/// [`Self::server_protocol_version`] `>= 5`.
/// [`Self::session_protocol_version`] `>= 5`.
pub fn send_pointer(
&self,
buffer_id: BufferId,
@ -944,7 +968,7 @@ impl AttachClient {
/// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the
/// terminal-cell geometry this frontend has on screen. Callers gate
/// on [`Self::server_protocol_version`] `>= 19`.
/// on [`Self::session_protocol_version`] `>= 19`.
///
/// Cells, never pixels — the frontend divides its own drawable
/// rectangle by its own metrics, keeping the no-pixels contract the
@ -963,7 +987,7 @@ impl AttachClient {
/// Send a `FrontendEvent::TerminalPointer` (Vterm Stage 3): a
/// gesture hit-tested locally to a terminal cell. Callers gate on
/// [`Self::server_protocol_version`] `>= 19`.
/// [`Self::session_protocol_version`] `>= 19`.
pub fn send_terminal_pointer(
&self,
buffer_id: BufferId,
@ -996,9 +1020,22 @@ impl AttachClient {
})
}
/// The daemon's negotiated wire version from `Hello`.
pub fn server_protocol_version(&self) -> u32 {
self.server_protocol_version
/// The version this session negotiated — the frontend's
/// `AttachRequest` offer, which the daemon adopts.
///
/// Every "is this variant on the wire?" gate keys on this, never on
/// [`Self::baseline_protocol_version`].
pub fn session_protocol_version(&self) -> u32 {
self.session_protocol_version
}
/// The compatibility baseline the daemon advertised in `Hello`.
///
/// Only the handshake itself needs this. It is *lower* than
/// [`Self::session_protocol_version`] whenever an additive family has
/// been activated by counter-offer, which is the normal case.
pub fn baseline_protocol_version(&self) -> u32 {
self.baseline_protocol_version
}
/// Send a locally-authored CRDT operation to the daemon. The GPU
@ -1385,7 +1422,8 @@ mod tests {
outbox: Arc::new((Mutex::new(outbox), Condvar::new())),
shutdown_handle: b,
frontend_id: FrontendId::LOCAL,
server_protocol_version: PROTOCOL_VERSION,
session_protocol_version: PROTOCOL_VERSION,
baseline_protocol_version: pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
initial_message: None,
};
// A send against the closed outbox fails *and* shuts the socket
@ -1546,7 +1584,16 @@ mod tests {
.expect("transient sequence must attach");
assert_eq!(attempts, 4);
assert!(managed.daemon.spawned_daemon());
assert_eq!(managed.client.server_protocol_version(), PROTOCOL_VERSION);
assert_eq!(
managed.client.session_protocol_version(),
PROTOCOL_VERSION,
"a managed attach negotiates this binary's wire, not the Hello baseline"
);
assert_eq!(
managed.client.baseline_protocol_version(),
pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
"while the daemon's server-first Hello still advertises the baseline"
);
server.join().expect("handshake server");
}

View File

@ -751,7 +751,8 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
state.set_frontend_id(client.frontend_id());
let mut facts = ProbeFacts {
server_protocol_version: client.server_protocol_version(),
session_protocol_version: client.session_protocol_version(),
baseline_protocol_version: client.baseline_protocol_version(),
..ProbeFacts::default()
};
@ -889,8 +890,13 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
let mut out = String::new();
let _ = writeln!(
out,
"server_protocol_version={}",
facts.server_protocol_version
"session_protocol_version={}",
facts.session_protocol_version
);
let _ = writeln!(
out,
"baseline_protocol_version={}",
facts.baseline_protocol_version
);
let _ = writeln!(out, "declarations={}", facts.declarations);
let _ = writeln!(out, "frames={}", facts.frames);
@ -974,7 +980,8 @@ fn run_headless_managed_probe(
return 7;
}
let daemon = managed.daemon;
let protocol = client.server_protocol_version();
let protocol = client.session_protocol_version();
let baseline = client.baseline_protocol_version();
let (stdin_tx, stdin_rx) = mpsc::channel();
std::thread::Builder::new()
@ -998,6 +1005,7 @@ fn run_headless_managed_probe(
report,
"ready",
protocol,
baseline,
&daemon,
&buffer_facts,
&disconnect,
@ -1028,6 +1036,7 @@ fn run_headless_managed_probe(
report,
"ready",
protocol,
baseline,
&daemon,
&buffer_facts,
&disconnect,
@ -1061,6 +1070,7 @@ fn run_headless_managed_probe(
report,
"ready",
protocol,
baseline,
&daemon,
&buffer_facts,
&disconnect,
@ -1081,6 +1091,7 @@ fn run_headless_managed_probe(
report,
"complete",
protocol,
baseline,
&daemon,
&buffer_facts,
&disconnect,
@ -1138,6 +1149,7 @@ fn write_managed_probe_report(
report: &Path,
phase: &str,
protocol: u32,
baseline: u32,
daemon: &attach::ManagedDaemonFacts,
buffer_facts: &ManagedProbeBufferFacts,
disconnect: &str,
@ -1146,7 +1158,8 @@ fn write_managed_probe_report(
let mut out = String::new();
let _ = writeln!(out, "phase={phase}");
let _ = writeln!(out, "server_protocol_version={protocol}");
let _ = writeln!(out, "session_protocol_version={protocol}");
let _ = writeln!(out, "baseline_protocol_version={baseline}");
let _ = writeln!(out, "buffer_snapshot=true");
let _ = writeln!(out, "buffer_snapshots={}", buffer_facts.snapshots);
let _ = writeln!(
@ -1181,7 +1194,16 @@ fn write_probe_report(report: &Path, contents: &str) -> std::io::Result<()> {
/// Named observations the headless probe reports back to the acceptance.
#[derive(Default)]
struct ProbeFacts {
server_protocol_version: u32,
/// The version the SESSION negotiated (this frontend's counter-offer).
session_protocol_version: u32,
/// The compatibility baseline the daemon advertised in `Hello`.
///
/// Reported beside the negotiated version rather than instead of it:
/// Stage 2B-3's whole activation claim is that these two DIFFER — the
/// daemon still advertises a version every shipped frontend accepts
/// while this session speaks the newer wire — and a report carrying
/// only one of them cannot express that.
baseline_protocol_version: u32,
declarations: u32,
frames: u32,
rendered_nonuniform_frames: u32,
@ -1993,14 +2015,14 @@ impl App {
let Some(client) = self.attach_client.as_ref() else {
return;
};
if client.server_protocol_version() < 5 {
if client.session_protocol_version() < 5 {
return;
}
// TripleDown is a v7 variant; a pre-v7 instance would
// hard-error decoding it. Downgrade to a plain Down — the
// exact behavior the third click had before v7 (the chain
// restarting).
let kind = if kind == PointerKind::TripleDown && client.server_protocol_version() < 7 {
let kind = if kind == PointerKind::TripleDown && client.session_protocol_version() < 7 {
PointerKind::Down
} else {
kind
@ -2008,7 +2030,7 @@ impl App {
// Context (right-click, Q#CM1) is a v11 variant; a pre-v11
// instance can't open a menu, so drop the gesture rather than
// sending an undecodable variant.
if kind == PointerKind::Context && client.server_protocol_version() < 11 {
if kind == PointerKind::Context && client.session_protocol_version() < 11 {
return;
}
if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) {
@ -2030,7 +2052,7 @@ impl App {
let Some(client) = self.attach_client.as_ref() else {
return;
};
if client.server_protocol_version() < 19 {
if client.session_protocol_version() < 19 {
return;
}
if let Err(e) = client.send_terminal_pointer(buffer_id, coord, kind, mods) {
@ -2047,7 +2069,7 @@ impl App {
let Some(client) = self.attach_client.as_ref() else {
return;
};
if client.server_protocol_version() < 19 {
if client.session_protocol_version() < 19 {
return;
}
let Some(state) = self.state.as_mut() else {
@ -2089,7 +2111,7 @@ impl App {
let Some(client) = self.attach_client.as_ref() else {
return;
};
if client.server_protocol_version() < 11 {
if client.session_protocol_version() < 11 {
return;
}
if let Err(e) = client.send_menu_pointer(index, invoke) {

View File

@ -68,7 +68,7 @@ pub use message::{
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace,
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
negotiate_capabilities,
negotiate_capabilities, negotiated_session_version, requested_protocol_version,
};
pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload};
pub use terminal::{

View File

@ -1667,14 +1667,87 @@ pub const PROTOCOL_VERSION: u32 = 21;
/// Protocol version placed in the daemon's server-first [`Hello`].
///
/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but
/// production attachment remains on v20 until the Stage 2B-3 capability
/// activation can preserve compatibility with existing v20 frontends.
/// Those frontends reject an unknown server-first version before they can
/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would
/// make the otherwise-dark protocol slice user-visible.
/// **This is a compatibility *baseline*, not a ceiling, and Stage 2B-3
/// makes that permanent.** The handshake is server-first: the daemon
/// writes [`Hello`] before the frontend has said anything at all, and a
/// frontend rejects an unrecognized `protocol_version` *before* it can
/// send [`AttachRequest`]. Advertising [`PROTOCOL_VERSION`] here would
/// therefore lock out every already-shipped frontend whose supported
/// range ends lower — an incompatible act on its own, independent of
/// whether a single new message is ever exchanged.
///
/// So the baseline stays at the highest version every shipped frontend
/// is known to accept, and the session's actual version is settled by
/// the frontend's [`AttachRequest`] instead:
///
/// 1. the daemon advertises this baseline;
/// 2. the frontend answers with [`requested_protocol_version`] — its own
/// [`PROTOCOL_VERSION`] when the baseline is this constant, and a
/// verbatim echo of anything older;
/// 3. the daemon negotiates [`negotiated_session_version`] of that offer.
///
/// A shipped baseline-version frontend echoes the baseline and gets a
/// baseline session, exactly as before. A current frontend offers up and
/// gets the current wire. Nothing about the `Hello` encoding or its
/// value changes, which is why the old frontend never sees a version it
/// must reject.
///
/// Moving this constant is therefore a **deliberately incompatible**
/// act, reserved for a wire change that cannot be expressed additively.
/// An additive family — like the bottom panel's v21 shapes — never needs
/// it.
pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20;
/// The version a frontend puts in its [`AttachRequest`], given the
/// server-first [`Hello`] baseline it just read.
///
/// The counter-offer is confined to the *current* baseline on purpose. A
/// daemon advertising anything other than [`ADVERTISED_PROTOCOL_VERSION`]
/// is genuinely older than this ladder rung, so its baseline is echoed
/// verbatim and that attachment takes byte-for-byte the pre-Stage-2B-3
/// path. Only the current baseline — the one every daemon built from
/// this ladder sends — is answered with this binary's own
/// [`PROTOCOL_VERSION`].
///
/// The offer is never *lower* than the baseline: a frontend that
/// supported less than the daemon advertised would already have rejected
/// the `Hello` via [`is_supported_protocol_version`].
///
/// # The one-way window this leaves open
///
/// A daemon whose own `PROTOCOL_VERSION` *equals* the baseline also
/// advertises the baseline, and rejects an offer above its supported
/// range with [`GoodbyeReason::VersionMismatch`]. That is the price of a
/// server-first handshake with no client-first hint: compatibility can
/// be preserved for old *frontends* (the direction that matters, since
/// the daemon is what a user leaves running) or for old *daemons*, but a
/// single `AttachRequest` cannot mean both "I want 21" and "≤ 20" at
/// once. The window closes as soon as the running daemon is restarted on
/// a binary from this ladder rung or later, and it is one-connection
/// visible — [`GoodbyeReason::VersionMismatch`] names both versions.
#[must_use]
pub fn requested_protocol_version(server_baseline: u32) -> u32 {
if server_baseline == ADVERTISED_PROTOCOL_VERSION {
PROTOCOL_VERSION
} else {
server_baseline
}
}
/// The version a daemon records for a session, given the frontend's
/// [`AttachRequest`] offer.
///
/// The offer has already passed [`is_supported_protocol_version`], so
/// this clamp cannot bind today; it is here because "the session speaks
/// the lower of the two ceilings" is the *rule*, and leaving it implicit
/// in a membership test is how a future ladder widening (accepting a
/// version this binary cannot itself produce) would silently ship an
/// over-negotiated session.
#[must_use]
pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
frontend_offer.min(PROTOCOL_VERSION)
}
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat

View File

@ -450,8 +450,15 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> {
// its AttachRequest (the v0.1 daemon's strict-equality check will
// accept). The frontend's runtime behavior on the wire is the
// intersection of features both sides support.
//
// Bottom-panel Stage 2B-3: that echo is now a *floor*, not the whole
// rule. `Hello` is server-first, so the daemon must advertise a
// baseline every shipped frontend accepts; the session's real version
// is settled here, by this frontend counter-offering its own
// `PROTOCOL_VERSION` when the baseline is the current one. Anything
// older is still echoed verbatim.
let req = AttachRequest {
protocol_version: hello.protocol_version,
protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version),
frontend_capabilities: build_capabilities(),
initial_size,
};
@ -1914,9 +1921,12 @@ fn run_one_session(
// T M10.5: match the server's protocol version so v1.0 frontends
// attaching to v0.1 daemons advertise protocol_version=1. Same
// pattern as the local-socket path above.
// pattern as the local-socket path above, including Stage 2B-3's
// counter-offer: this path *spawns* the daemon from the running
// executable, so the peer is always this same ladder rung and the
// counter-offer is always accepted.
let req = AttachRequest {
protocol_version: hello.protocol_version,
protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version),
frontend_capabilities: build_capabilities(),
initial_size,
};

View File

@ -735,6 +735,15 @@ fn per_attach_thread(
daemon_debug(format!("received AttachRequest from {frontend_id:?}"));
// T M10.5 version check.
//
// Bottom-panel Stage 2B-3: this is where a session's version is
// actually settled. `Hello` above carried only the compatibility
// BASELINE (`ADVERTISED_PROTOCOL_VERSION`) — it has to, because a
// server-first handshake reaches a shipped frontend before that
// frontend can say anything, and a version it does not recognize is
// rejected outright. The frontend's counter-offer is therefore the
// upper half of the negotiation, and this membership test is what
// bounds it.
if !crate::protocol::is_supported_protocol_version(req.protocol_version) {
let _ = write_message(
&mut stream,
@ -812,8 +821,16 @@ fn per_attach_thread(
u8::try_from(frontend_id.0 % (crate::overlay_color::PALETTE_LEN as u64)).unwrap_or(0)
};
let session_state =
crate::presence::SessionState::new(req.protocol_version, negotiated_caps, color_slot);
// The session speaks the lower of the two ceilings. The membership
// test above already bounds the offer, so this clamp cannot bind
// today; it is applied through the shared rule anyway so a future
// ladder widening cannot silently record a version this binary is
// unable to produce.
let session_state = crate::presence::SessionState::new(
crate::protocol::negotiated_session_version(req.protocol_version),
negotiated_caps,
color_slot,
);
// Hand the write-half to the dispatcher; keep a read-half for
// this thread's reader loop. **Reader loop starts immediately
@ -899,23 +916,30 @@ fn peer_declared_terminal_support(
///
/// Grid sessions paint the whole cell grid the daemon composes, so a side
/// window is just another leaf for them. A semantic session needs the GPU
/// band, which does not exist yet — so this still answers `false` for
/// every semantic peer, whatever it declares. No client-asserted
/// band, which Stage 2B-3 lands — so it is panel-capable exactly when it
/// negotiated a wire that can carry the band. No client-asserted
/// standalone boolean is trusted: the answer is derived from the daemon's
/// own negotiated state.
///
/// **Stage 2B-2 deliberately does not turn the version arm on.** The
/// daemon-side projection and epoch machine below are complete and
/// exercised through a test-only panel-capable view, but the production
/// flip (`semantic_render && negotiated_protocol_version >=
/// PANEL_MIN_VERSION`) belongs to Stage 2B-3, together with the
/// compatibility-preserving activation the server-first `Hello` requires:
/// **Stage 2B-3 turns the version arm on** (framing §3.5): `panel_capable`
/// is true for an authenticated semantic session that negotiated
/// [`PANEL_MIN_VERSION`] or later, and false for every earlier one. The
/// gate is on *placement*, not only on transport — denying the events
/// while still putting a pre-panel peer's window in a side panel it cannot
/// render would leave that window invisible, so a v6v20 semantic session
/// keeps the Stage 1 fallback with every side-specific parameter
/// discarded (Q#BP2c).
///
/// The version reaching this predicate is the *negotiated* one, which is
/// the frontend's `AttachRequest` counter-offer rather than the
/// [`ADVERTISED_PROTOCOL_VERSION`](pmacs_protocol::ADVERTISED_PROTOCOL_VERSION)
/// is still 20, so no session can negotiate 21 yet, and denying only the
/// events while still *placing* such a peer in a side window would leave
/// its window invisible.
/// baseline the daemon put in `Hello`. That distinction is the whole
/// activation mechanism: the baseline stays where every shipped frontend
/// can accept it, and only a frontend that named the newer wire itself
/// becomes panel-capable.
fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool {
!session_state.negotiated_capabilities.semantic_render
|| session_state.negotiated_protocol_version >= PANEL_MIN_VERSION
}
/// The same belt-and-braces write-loop gate for the additive
@ -1959,10 +1983,11 @@ fn handle_session_established(
// collapses folds, a semantic one keeps raw-line reckoning until
// Stage 3.
// Bottom-panel arc (Q#BP13): panel capability comes from the SAME
// negotiated bit in this same transaction. Stage 1 ships the TUI
// side windows only, so a semantic session is not panel-capable and
// a `side` request falls back to its document target with every
// side-specific parameter discarded.
// negotiated state in this same transaction. Stage 2B-3 made the
// semantic arm live: a semantic session that negotiated
// `PANEL_MIN_VERSION` or later can render the GPU band and is
// panel-capable, while a v6-v20 semantic session still falls back to
// its document target with every side-specific parameter discarded.
let fresh_view = build_fresh_frontend_view(
editor,
!session_state.negotiated_capabilities.semantic_render,
@ -5570,7 +5595,7 @@ mod tests {
fn viewport_aligns_the_document_without_taking_focus_from_the_panel() {
let (mut editor, fid, document, panel) = panel_focused_semantic_fixture();
let other = {
let mut core = editor.core.borrow_mut();
let core = editor.core.borrow_mut();
core.registry.borrow_mut().create("*other*")
};

View File

@ -579,9 +579,19 @@ mod crdt {
);
let facts = parse_report(&report);
assert_eq!(facts.get("phase").map(String::as_str), Some("complete"));
// Stage 2B-3: the negotiated session version and the advertised
// baseline are different facts, and this managed spawn pins both.
// The daemon it spawned advertises the baseline; the client it
// handed back counter-offered this binary's own wire.
assert_eq!(
facts
.get("server_protocol_version")
.get("session_protocol_version")
.and_then(|value| value.parse::<u32>().ok()),
Some(PROTOCOL_VERSION)
);
assert_eq!(
facts
.get("baseline_protocol_version")
.and_then(|value| value.parse::<u32>().ok()),
Some(ADVERTISED_PROTOCOL_VERSION)
);

View File

@ -717,10 +717,25 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
.filter_map(|line| line.split_once('='))
.collect();
// Bottom-panel Stage 2B-3 activated v21 by frontend counter-offer, so
// the two halves of the handshake now report DIFFERENT numbers and both
// are load-bearing: the daemon still advertises the v20 compatibility
// baseline in its server-first `Hello` (so a shipped v20 frontend is
// never handed a version it must reject), while this real client
// negotiated v21 and is therefore panel-capable. Asserting only the
// session version would pass if the baseline had been bumped too —
// which is exactly the incompatible change this mechanism exists to
// avoid — and asserting only the baseline would pass with the whole
// activation missing.
assert_eq!(
facts.get("server_protocol_version").copied(),
facts.get("session_protocol_version").copied(),
Some("21"),
"the real client must negotiate the v21 panel wire: {text}"
);
assert_eq!(
facts.get("baseline_protocol_version").copied(),
Some("20"),
"the dark v21 wire slice must keep the real client on v20: {text}"
"while the daemon's server-first Hello still advertises v20: {text}"
);
assert_eq!(
facts.get("entered_terminal_mode").copied(),