test: close conversation storage follow-up gates (#3742)

This commit is contained in:
Carlos Florêncio
2026-09-17 10:39:30 +01:00
committed by GitHub
parent 5450be9d07
commit 2eee90d38e
115 changed files with 10810 additions and 7533 deletions
+184 -359
View File
@@ -695,386 +695,211 @@ that already filters to this plugin's own events, applies your `scope`/
tab's own writes (so an editor never clobbers its own caret/selection from its
own write).
### `host.conversation` — live paginated session history
### host.conversation - live paginated session history
The Host-only conversation contract is source-backed. The
[source reconciliation plan](../conversation-storage-replacement/plan.md) and
its system design define storage and transport behavior; this section defines
the browser-visible API and the private v2 wire shape.
This browser-only facade requires the manifest capability
`capabilities.api_read: ["messages"]` and `min_kandev_version: "0.91.1"` or
higher. The same floor applies to the Go `Messages().List` reader. It is the
only supported browser way to read prompt history and does not expose Zustand
state, first-party `/api/v1` URLs, raw content, arbitrary metadata, or raw
WebSocket frames. The Host binds every request and event to the current
plugin generation and task-panel session context.
capabilities.api_read: ["messages"] and min_kandev_version: "0.91.1" or higher.
It is the only supported browser way to read prompt history. It does not expose
Zustand state, first-party /api/v1 URLs, raw content, arbitrary metadata, cursors,
revision tokens, or WebSocket payloads. The Host binds every request and
notification to the current plugin generation and task-panel session context.
```ts
type PluginConversationErrorCode =
| "unauthenticated"
| "not_found"
| "invalid_query"
| "upstream_failure";
type PluginConversationErrorCode =
| "unauthenticated"
| "not_found"
| "invalid_query"
| "upstream_failure";
interface PluginConversationError {
code: PluginConversationErrorCode;
message: string;
retryable: boolean;
}
Browser conversation routes map `401` to `unauthenticated` with
`retryable:false`, `404` to `not_found` with `retryable:false`, `400` to
`invalid_query` with `retryable:false`, and every authorized upstream `5xx` to
`upstream_failure` with `retryable:true`. Binding-only
`409/generation_superseded` is not plugin-visible; continuation-renewal
`409` is plugin-visible as retryable `upstream_failure`.
interface PluginConversationError {
code: PluginConversationErrorCode;
message: string;
retryable: boolean;
}
type PluginSessionKind = "managed" | "passthrough" | null;
type PluginConversationAuthor = "user" | "agent";
type PluginConversationSort = "asc" | "desc";
type PluginConversationAuthor = "user" | "agent";
type PluginConversationSort = "asc" | "desc";
interface PluginConversationMessage {
id: string;
taskId: string | null;
sessionId: string;
turnId?: string;
authorType: PluginConversationAuthor;
type: string;
content: string;
createdAt: string;
updatedAt: string;
promptIndex?: number;
senderTaskId?: string;
}
interface PluginConversationMessage {
id: string;
taskId: string | null;
sessionId: string;
turnId?: string;
authorType: PluginConversationAuthor;
type: string;
content: string;
createdAt: string;
updatedAt: string;
promptIndex?: number;
senderTaskId?: string;
}
interface PluginConversationTurn {
id: string;
taskId: string | null;
sessionId: string;
startedAt: string;
completedAt?: string;
updatedAt: string;
}
interface PluginSessionMessagesQuery {
sessionId: string | null;
taskId?: string | null;
authorTypes?: readonly PluginConversationAuthor[];
sort?: PluginConversationSort;
pageSize?: number;
}
interface PluginConversationTurn {
id: string;
taskId: string | null;
sessionId: string;
startedAt: string;
completedAt?: string;
updatedAt: string;
}
interface PluginSessionMessagesState {
messages: readonly PluginConversationMessage[];
loading: boolean;
hydrated: boolean;
loadingMore: boolean;
error: PluginConversationError | null;
hasMore: boolean;
removed: boolean;
loadMore(): Promise<number>;
retry(): void;
}
interface PluginSessionMessagesQuery {
sessionId: string | null;
taskId?: string | null;
authorTypes?: readonly PluginConversationAuthor[];
sort?: PluginConversationSort;
pageSize?: number;
}
interface PluginSessionTurnsState {
turns: readonly PluginConversationTurn[];
loading: boolean;
hydrated: boolean;
error: PluginConversationError | null;
removed: boolean;
retry(): void;
}
interface PluginSessionMessagesState {
messages: readonly PluginConversationMessage[];
loading: boolean;
hydrated: boolean;
loadingMore: boolean;
error: PluginConversationError | null;
hasMore: boolean;
removed: boolean;
loadMore(): Promise<number>;
retry(): void;
}
interface PluginConversationApi {
useSessionMessages(
query: PluginSessionMessagesQuery,
): PluginSessionMessagesState;
useSessionTurns(
sessionId: string | null,
taskId?: string | null,
): PluginSessionTurnsState;
useMessageFavorite(sessionId: string | null, messageId: string): boolean;
}
```
interface PluginSessionTurnsState {
turns: readonly PluginConversationTurn[];
loading: boolean;
hydrated: boolean;
error: PluginConversationError | null;
removed: boolean;
retry(): void;
}
Within a task-panel render, `host.conversation` resolves the Host-injected
panel scope; `props.conversation.history` is the equivalent explicit handle.
Outside a panel scope, nullable session reads return empty state. `taskId` is tri-state: `undefined` (field omitted) inherits the active panel context task via Host-side resolution before sending, so the server never observes `undefined`; explicit `null` means session-scoped with no task filter and encodes as absent `task_id`; an explicit string must equal the panel context task or the query fails `400/invalid_query` without network activity. An explicit string sends as its value; empty `task_id=` is `400`. Event `task_id` and public DTO `taskId` are nullable (`string | null`), with `null` for session-scoped rows. Cache identity, cursor fingerprint, and live-event filtering all key on the tri-state tag, so `undefined`, `null`, and `"t1"` never collapse; wrong-session or wrong-task events are dropped before merge.
Live event matching is explicit: inherited `undefined` and an explicit task
string accept only events whose `session_id` and `task_id` equal the resolved
query context. Explicit `null` accepts every task ID in the selected session,
including event `task_id: null`. A `session.removed` barrier is accepted for
the selected session regardless of its event task ID and closes both hooks.
interface PluginConversationApi {
useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState;
useSessionTurns(sessionId: string | null, taskId?: string | null): PluginSessionTurnsState;
useMessageFavorite(sessionId: string | null, messageId: string): boolean;
}
`loadMore()` resolves to the number of newly projected messages. Joined calls
resolve to the same committed count. Exhausted, closed, and terminal handles
resolve `0` without network activity; retryable transport or renewal failures
reject with `PluginConversationError` while leaving committed state unchanged.
Non-retryable query errors also reject and remain in `error`; `retry()` clears
the error and starts a fresh snapshot only for a live, retryable handle.
Turn `retry()` follows the same live-handle rule: it clears a retryable error
and starts a fresh ready snapshot, while closed, removed, exhausted, and
non-retryable handles do not issue network requests.
Each mounted `PluginTaskPanel` is wrapped in a Host-owned scope provider with its own cache, consumer identity, and abort controller. `history` is the authoritative panel handle; global `host.conversation` resolves only the nearest scope. Unmount or any plugin/panel/task/session/generation change aborts in-flight work, releases that consumer, and closes the handle. Closed handles and calls outside a scope return the stable empty state without network activity; two panels never share scope state. Message pages are bounded, newest-first by default, deterministic keyset pages with opaque cursors. `loadMore` is idempotent and preserves cursor, sort, filters, and generation. The Host owns cutoff, sidecars, replay, gaps, deduplication, tombstones, and lifecycle abort; none is public DTO state.
Lifecycle invalidation also includes a presentation change. `loadMore` is
idempotent operationally: concurrent calls for the same committed continuation
join one in-flight request, and calls after `hasMore` is `false` resolve without
network activity.
On `session.removed`, the facade retains already projected rows, sets
`removed` to `true`, sets `hasMore` to `false` for messages, and makes
subsequent `loadMore` and retry operations no-ops without network activity.
The `removed` terminal state is observable and stable; subsequent retries and
pagination calls resolve without network activity.
Each panel scope owns a monotonic `continuation_revision` over `{cursor,snapshot_token,cutoff,fingerprint,generation}` starting at sentinel `0` on scope creation. Query identity change, explicit `retry`, and close/invalidate increment the revision (close moves to an invalid tombstone that rejects all in-flight completions). Renewal and `loadMore` serialize dispatch per scope and CAS-install only when the current revision still equals the captured one; every response is checked against both the revision and the full tuple. Renewal may change only expiry, token rotation, and cursor re-encoding while preserving cutoff and fingerprint. Failed renewal or failed page leaves the committed page state unchanged; close or retry invalidates pending completions.
The HTTP facade returns the public page shapes above. The Host transport carries the subscribe ACK's `snapshot_token` in a Host-only request header; it remains valid through the signed cursor/query expiry (maximum 10 minutes) and rotates only on rebind. The server validates its plugin/session/generation binding and committed cutoff, then returns internal row-sequence sidecars. The Host strips those sidecars before this API resolves. Cursors reject tampering or a different session, task, filter, sort, generation, or expiry; reads are not caller-selectable as-of queries.
The Host renews a continuation before 80% of expiry through its private `POST /api/plugins/{id}/conversation/continuation/renew` operation. It accepts the current cursor and snapshot token, returns replacements with the same cutoff/fingerprint and a new expiry, and never selects a new cutoff. If retention or generation cannot preserve the cutoff, `loadMore` leaves existing state unchanged and reports retryable `upstream_failure`; explicit `retry` starts a fresh snapshot.
Within a task-panel render, host.conversation resolves the Host-injected panel
scope; props.conversation.history is the equivalent explicit handle. Outside a
panel scope, nullable session reads return empty state. taskId is tri-state:
undefined inherits the active panel task, null selects every task in the session,
and an explicit string must equal the panel task. A mismatch fails before
network activity. Cache identity, cursor fingerprints, and live filtering retain
that tri-state distinction.
Endpoint error matrix: `GET /conversation/binding` `409/generation_superseded/true` is Host-loader-only (bounded retry, never import until success, `Cache-Control: no-store` on success and every error). `POST /conversation/continuation/renew` `409` maps to public retryable `upstream_failure` without changing the loaded cutoff, also `Cache-Control: no-store`. Plugin-visible errors never carry `generation_superseded`.
Binding tokens expire no later than 10 minutes after issuance; the loader
rebinds before expiry and never sends an expired token to plugin code.
The Host establishes ordered readiness before the initial snapshot. The Host-only
request uses the discriminated `SubscribeRequest` branches below: core requires
`wire_id`; plugin requires `plugin_id`, `generation`, `binding_token`, and
`consumer_id`; identities are mutually exclusive. Success returns the matching
identity, while failure has no success-only fields. Legacy payloads are
accepted only with all ordered fields absent.
Messages and turns are read from current source rows in bounded deterministic
keyset pages. The Host maps safe DTOs, strips system content and arbitrary
metadata, and keeps source cursors private. It owns page invalidation, retries,
deduplication, recovery, and lifecycle abort. The public state retains projected
rows when session.removed arrives, sets removed to true, and stops pagination and
retry without issuing more network requests. Each mounted panel has an
independent scope, cache, Host-minted identity, and abort controller.
#### Host-only conversation wire contract
The Host reconciles source notifications by epoch and decimal revision. A
matching complete receipt applies its operations by entity ID. A reset marker,
malformed payload, wrong scope, epoch change, revision gap, or failed operation
starts a fresh source read. Notifications received before their matching
snapshot commits remain buffered. A source read and its revision are checked
together, then the buffered changes are applied. There is no durable payload
journal, ACK protocol, poison queue, replay promise, content hash, or caller
selectable as-of read.
The Host-only snapshot header is `X-Kandev-Snapshot-Token`. HTTP page and
renewal shapes, plus ordered event payloads, are:
#### Host-only v2 conversation wire contract
```ts
type CoreSubscribeRequest = {
session_id: string;
consumer_kind: "core";
last_seen_sequence?: number;
resume_token?: string;
wire_id: string;
plugin_id?: never;
generation?: never;
binding_token?: never;
consumer_id?: never;
};
type PluginSubscribeRequest = {
session_id: string;
consumer_kind: "plugin";
plugin_id: string;
generation: number;
binding_token: string;
last_seen_sequence?: number;
resume_token?: string;
consumer_id: string;
wire_id?: never;
};
type SubscribeRequest = CoreSubscribeRequest | PluginSubscribeRequest;
type SessionWireFailureCode =
| "unauthorized"
| "invalid_binding"
| "generation_superseded"
| "invalid_request"
| "session_not_found"
| "cursor_expired"
| "forward_gap"
| "session_removed";
type SessionWireError = {
code: SessionWireFailureCode;
message: string;
retryable: boolean;
};
type SubscribeSuccessPayload = {
success: true;
session_id: string;
result: "fresh" | "replay" | "gap" | "invalid_resume";
event_watermark: number;
snapshot_cutoff: number;
snapshot_token: string;
replay_from?: number;
replay_to?: number;
oldest_sequence?: number;
resume_token: string;
} & (
| { wire_id: string; consumer_id?: never }
| { consumer_id: string; wire_id?: never }
);
type SubscribeFailurePayload = {
success: false;
session_id?: string;
error: SessionWireError;
};
type SubscribeAck = {
id: string;
type: "response";
action: "session.subscribe";
payload: SubscribeSuccessPayload | SubscribeFailurePayload;
};
type CoreSessionAckRequest = {
session_id: string;
consumer_kind: "core";
sequence: number;
resume_token?: string;
wire_id: string;
plugin_id?: never;
generation?: never;
consumer_id?: never;
};
type PluginSessionAckRequest = {
session_id: string;
consumer_kind: "plugin";
plugin_id: string;
generation: number;
sequence: number;
resume_token?: string;
consumer_id: string;
wire_id?: never;
};
type SessionAckRequest = CoreSessionAckRequest | PluginSessionAckRequest;
type SessionAckSuccessPayload = {
success: true;
session_id: string;
acknowledged_sequence: number;
resume_token: string;
} & (
| { wire_id: string; consumer_id?: never }
| { consumer_id: string; wire_id?: never }
);
type SessionAckFailurePayload = {
success: false;
session_id?: string;
error: SessionWireError;
};
type SessionAckResponse = {
id: string;
type: "response";
action: "session.ack";
payload: SessionAckSuccessPayload | SessionAckFailurePayload;
};
type PluginConversationPage = {
messages: readonly PluginConversationMessage[];
hasMore: boolean;
cursor: string | null;
};
type PluginConversationTurnsPage = {
turns: readonly PluginConversationTurn[];
};
type ContinuationRenewRequest = {
cursor: string;
snapshot_token: string;
};
type ContinuationRenewResponse = {
cursor: string;
snapshot_token: string;
cutoff: number;
fingerprint: string;
expires_at: string;
};
type RawSessionEvent = {
type: "session.event";
protocol_version: number;
event_type: string;
session_id: string;
task_id: string | null;
sequence: number;
event_id: string;
payload: unknown;
};
The private subscribe actions are session.conversation.subscribe and
session.conversation.unsubscribe. A request uses this shape:
// Validation converts RawSessionEvent to this projected union only after
// protocol, identity, event-type, and payload checks succeed.
type SessionEventPayload =
| { event_type: "message.added"; message: PluginConversationMessage }
| { event_type: "message.updated"; message: PluginConversationMessage }
| { event_type: "message.deleted"; message_id: string }
| { event_type: "session.turn.started"; turn: PluginConversationTurn }
| { event_type: "session.turn.completed"; turn: PluginConversationTurn }
| { event_type: "session.removed" };
type SessionEventBase = {
type: "session.event";
protocol_version: 1;
session_id: string;
task_id: string | null;
sequence: number;
event_id: string;
};
type SessionEvent = {
[T in SessionEventPayload["event_type"]]: SessionEventBase & {
event_type: T;
payload: Extract<SessionEventPayload, { event_type: T }>;
};
}[SessionEventPayload["event_type"]];
```
type ConversationSubscribeRequest = {
protocol_version: 2;
scope_id: string;
session_id: string;
consumer_kind: "core" | "plugin";
plugin_id?: string;
generation?: number;
binding_token?: string;
task_id?: string | null;
authors?: string[];
sort?: "asc" | "desc";
};
The Host sends `X-Kandev-Snapshot-Token` on snapshot reads and validates the
renewal request before exposing only the public page state. `cursor` is
nullable only on a page with no next page; renewal receives and returns the
current non-null cursor. The Host maps event payloads to public DTOs and
strips protocol sidecars before plugin code sees them. Removal is terminal
whether it precedes or follows poison; the replacement cursor is closed.
Only `message.added`, `message.updated`, `message.deleted`,
`session.turn.started`, `session.turn.completed`, and `session.removed` use
this migrated contract.
The raw envelope is decoded with `protocol_version: number` and arbitrary
`event_type`/`payload`; `SessionEvent` is the validated projected union.
Validation requires `payload.type === event_type`; a mismatch is durable
poison, is not projected, and cannot advance ACK. Strict subscribe ACKs require
`session_id`, `result`, `event_watermark`, `snapshot_cutoff`, `snapshot_token`,
and `resume_token`. `fresh` forbids replay and oldest-sequence fields;
`replay` requires `replay_from` and `replay_to` with
`replay_from <= replay_to <= snapshot_cutoff`.
For `replay`, `replay_from` is the first delivered sequence (inclusive) and
equals `last_seen_sequence + 1`; `replay_to` is the final delivered sequence
(inclusive) and equals the committed `snapshot_cutoff`.
`replay` is returned only when `last_seen_sequence` was supplied; otherwise
the initial result is `fresh`.
`gap` requires `oldest_sequence` and forbids replay fields; `invalid_resume`
forbids replay fields and reports the replacement cursor state. The
`resume_token` is always the current token for the returned consumer state.
Plugin requests include plugin_id, generation, and binding_token. Core requests
use consumer_kind: "core". The server validates the session through the normal
user/workspace boundary and rejects stale legacy session.subscribe payloads with
ordered fields. Unsubscribe uses the same scope and binding identity.
Subscribe and ACK failures use the same outer response envelope with
`success:false`, the requested `session_id` when known, and
`error:{code,message,retryable}`; no success-only ordered fields are present.
Codes distinguish `unauthorized`, `invalid_binding`, `generation_superseded`,
`invalid_request`, `session_not_found`, `cursor_expired`, `forward_gap`, and
`session_removed`. Binding/generation failures are non-projection failures;
`generation_superseded` is retryable only for the Host loader, while expired
or retained-cursor failures produce `gap` reconciliation rather than a
plugin-visible subscribe exception. ACK rejects forward gaps and malformed
identity/range/token input without advancing the durable cursor.
Core wire identity: the Host mints one opaque `wireId` per core consumer mount and carries it in subscribe/ACK state as `wire_id`. Plugin panels receive a separate Host-minted opaque `consumer_id`, carried in subscribe/ACK state and never caller-supplied. Broker state is keyed by `(session,consumer_kind,plugin,generation,wire_id|consumer_id)`; each applicable identity survives reconnect through its persisted entry, while a new mount mints a new identity at its registration watermark. Identities transition `registered -> ready -> streaming -> suspended (reconnect window) -> released` and release on unsubscribe/unmount or after the reconnect plus token-retention window expires. Plugin panels from one plugin/generation never share a cursor.
Reconnect grace is 10 minutes after disconnect. Resume and snapshot tokens
expire no later than 10 minutes after issuance; delivery cursors and immutable
event rows remain for the maximum token expiry plus the reconnect grace.
Garbage collection runs only after those bounds and preserves terminal
tombstones until all resumable consumer identities are released.
If `session.removed` precedes poison, the rebind still produces terminal
removal and a closed replacement cursor; no later event is admitted. This is
the same terminal outcome as poison followed by removal.
If a previously authorized consumer reconnects to `session_removed` or a
terminal replacement cursor, the facade preserves committed rows, sets
`removed:true`, sets message `hasMore:false`, and makes `loadMore` and `retry`
no-ops. A fresh unauthorized or nonexistent lookup remains ordinary
`404/not_found` and never reveals deletion state.
Ordered event envelopes carry `protocol_version`; canonical policy registry is `{1:{ignorable:["session.workspace_sources.updated"]}}`. Only a type listed by its matching version may advance ACK without projection. Missing, malformed, or unsupported versions and other unknown types remain durable poison: the cursor stays before the event, diagnostics retain its ID/version, and advancement stops. Rebind atomically snapshots past poison at a new cutoff, commits that cutoff, returns a replacement cursor/token, and does not project the poison. When `session.removed` arrives after a poison cursor, rebind snapshots past both to a terminal cutoff: the outcome is terminal removal, the replacement cursor is terminally closed and admits no post-terminal events, and poison diagnostics remain durable.
Poison operations are owned by `SessionDeliveryDispatcher`: each record moves
`pending -> leased -> exhausted -> requeued`, with a 30-second lease, five
attempts, and exponential backoff capped at 30 seconds. Exhaustion records
event ID, protocol version, and last error, and emits structured
`session_event_poison_total` diagnostics. The Host-only
`session.event.poison.requeue` command requires an authenticated operator with
`session_events:requeue`; it accepts session, event ID, and expected owner epoch,
audits actor, prior state, attempt count, and timestamp, increments owner epoch,
resets attempts, and returns `pending` without changing sequence or payload.
Repeated requests after requeue return current state without mutation; stale
expected epochs are rejected without mutation. Startup reclaims expired leases
but never clears or skips poison.
Legacy projection coalescing: pending contiguous sequence ranges project at most once per `event_id`/sequence per wire, then ACK the highest contiguous sequence. Example: `seq 10 = message.updated id A` followed by `seq 11 = message.updated id A` coalesces to one projection of the latest image `A@11`, then a single `session.ack` for `11`; a `session.removed` barrier after `11` is a separate terminal projection. Same-`event_id` replays and same-sequence redeliveries are dropped; any event with `sequence <= acknowledged_sequence` is suppressed without a second projection.
After contiguous delivery the Host-only `session.ack` uses the discriminated
`SessionAckRequest` branches below: core requires `wire_id`; plugin requires
`plugin_id`, `generation`, and `consumer_id`; identities are mutually exclusive.
Success returns the matching identity and failure has no success-only fields.
The shared client retains the full response envelope, validates outer
IDs/types/actions before payload parsing, and retries by consumer sequence/token.
The Host validates binding/generation, strict integer/range rules, applicable
identity, current or prior token on retry, and contiguous advancement. Snapshot
tokens and sidecars never reach plugin code. Reload, disable, uninstall, panel
switch, or unmount revokes generation.
type ConversationSubscribeSuccess = {
success: true;
protocol_version: 2;
scope_id: string;
session_id: string;
epoch: string;
revision: string;
};
Registry ownership matrix: every `register*` call and Host-owned resource is keyed by `(pluginId,generation,stageId,kind,localId)` and staged invisibly until commit. `kind` is the registration method (`route`, `navItem`, `settingsRoute`, `component`, `wsHandler`, `keybinding`, `integrationSettings`, `repositoryProvider`, `reviewProvider`, `taskAction`, `taskPanel`, `taskMenuAction`, `taskFilter`, `taskListFacet`, `translations`) or Host-owned resource (`styleLink`, `modal`, `conversationScope`, `bindingGrant`). A duplicate `(kind,localId)` within the same stage rejects the second call; a call from a foreign or retired `(pluginId,generation,stageId)` is rejected without side effects; stage abort discards all its staged entries without notifications. Commit atomically publishes the stage's entries and revokes the retired generation's entries in one linearization step, emitting notifications only for the committed delta.
type ConversationSubscribeFailure = {
success: false;
error: {
code: "invalid_request" | "invalid_binding" | "generation_superseded"
| "session_not_found" | "unauthorized" | "upstream_failure";
message: string;
retryable: boolean;
};
};
type ConversationChangeOperation = {
kind: "upsert" | "remove";
entity: "message" | "turn";
id: string;
message?: object;
turn?: object;
};
type ConversationChangedPayload = {
protocol_version: 2;
scope_id: string;
session_id: string;
epoch: string;
base_revision: string;
revision: string;
reset?: boolean;
operations: ConversationChangeOperation[];
};
The server publishes a changed payload only after the source transaction
commits. Complete mutation receipts carry the represented operations and the
base and committed revision. Incomplete receipts and uninstrumented writes
carry reset: true, so the client performs source reconciliation. An operation
may be filtered out for a task or author subscription while the revision still
advances; an empty operations array with matching revisions is a valid
coverage-only notification.
Revision values are decimal strings because JavaScript numbers cannot safely
represent every database revision. The process epoch changes after restart or
restore. The Host accepts only a newer contiguous revision in the current epoch,
buffers changes until snapshots commit, and recovers on a gap or epoch change.
A terminal session.removed notification is delivered through the normal
notification channel and closes every matching source scope.
HTTP pages are:
GET /api/plugins/{id}/conversation/v2/task-sessions/{sessionId}/messages
GET /api/plugins/{id}/conversation/v2/task-sessions/{sessionId}/turns
GET /api/plugins/{id}/conversation/v2/task-sessions/{sessionId}/revision
GET /api/plugins/{id}/conversation/binding
Source pages return the public records plus private epoch, revision, cursor, and
hasMore fields. expected_revision is an optional decimal guard. Binding and all
error responses use Cache-Control: no-store. Stable public errors are
401/unauthenticated, 404/not_found, 400/invalid_query, and authorized
5xx/upstream_failure. Binding failures may use a Host-only retryable
generation_superseded response; it never reaches plugin code.
The continuation-renew endpoint from the predecessor transport is not part of
the source contract. Load-more and retry use a current source read and preserve
the public state until that read succeeds. No browser code imports a persistence
store or writes conversation history.
## `registry: PluginRegistry`
```ts