From 413305878570321cf4532be47b9eea51d6749e9e Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 9 Jul 2026 16:02:51 -0700 Subject: [PATCH] Make changeset review a first-class capability Rework file review on top of the review support released in spec 0.5.2: - Add `Changeset.capabilities` with a `review` presence flag (`ChangesetCapabilities`) so a changeset advertises review support up-front on the session's changeset list, before a client subscribes. - Supersede the server-only `changeset/filesReviewedChanged` action with a client-dispatchable `changeset/filesReviewChanged` (field `fileIds` -> `files`), letting reviewers toggle a file's `reviewed` flag directly through the write-ahead reducer. Introduced in 0.6.0. - Regenerate schema and all client mirrors; update the hand-written Go, Kotlin, Rust, and Swift reducers and the changeset guide. - Swap the reducer conformance fixtures to the new action. Changelog is tracked via docs/.changes fragments per the fragment workflow; no CHANGELOG.md edits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 6 +- clients/go/ahptypes/actions.generated.go | 49 +++++--- clients/go/ahptypes/state.generated.go | 44 ++++++- .../microsoft/agenthostprotocol/Reducers.kt | 4 +- .../generated/Actions.generated.kt | 18 +-- .../generated/State.generated.kt | 45 ++++++- clients/rust/crates/ahp-types/src/actions.rs | 43 ++++--- clients/rust/crates/ahp-types/src/state.rs | 48 +++++++- clients/rust/crates/ahp/src/reducers.rs | 4 +- .../Generated/Actions.generated.swift | 22 ++-- .../Generated/State.generated.swift | 48 +++++++- .../Sources/AgentHostProtocol/Reducers.swift | 4 +- .../20260709-changeset-review-capability.json | 5 + ...-changeset-review-client-dispatchable.json | 5 + docs/guide/changesets.md | 50 +++++++- schema/actions.schema.json | 33 ++++-- schema/commands.schema.json | 33 ++++-- schema/errors.schema.json | 33 ++++-- schema/notifications.schema.json | 17 ++- schema/state.schema.json | 17 ++- scripts/generate-go.ts | 3 +- scripts/generate-kotlin.ts | 4 +- scripts/generate-rust.ts | 3 +- scripts/generate-swift.ts | 4 +- types/action-origin.generated.ts | 9 +- types/channels-changeset/actions.ts | 42 ++++--- types/channels-changeset/reducer.ts | 13 ++- types/channels-changeset/state.ts | 52 ++++++++- types/common/actions.ts | 6 +- ...et-filesreviewedchanged-sets-reviewed.json | 110 ------------------ ...-changeset-filesreviewedchanged-no-op.json | 51 -------- ...set-filesreviewchanged-marks-reviewed.json | 39 +++++++ ...et-filesreviewchanged-clears-reviewed.json | 41 +++++++ ...ilesreviewchanged-no-op-unknown-files.json | 29 +++++ types/version/registry.ts | 2 +- 35 files changed, 622 insertions(+), 314 deletions(-) create mode 100644 docs/.changes/20260709-changeset-review-capability.json create mode 100644 docs/.changes/20260709-changeset-review-client-dispatchable.json delete mode 100644 types/test-cases/reducers/160-changeset-filesreviewedchanged-sets-reviewed.json delete mode 100644 types/test-cases/reducers/161-changeset-filesreviewedchanged-no-op.json create mode 100644 types/test-cases/reducers/225-changeset-filesreviewchanged-marks-reviewed.json create mode 100644 types/test-cases/reducers/226-changeset-filesreviewchanged-clears-reviewed.json create mode 100644 types/test-cases/reducers/227-changeset-filesreviewchanged-no-op-unknown-files.json diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index e7ffacb3..dd5cbec7 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -1422,9 +1422,9 @@ func ApplyActionToChangeset(state *ahptypes.ChangesetState, action ahptypes.Stat } return ReduceOutcomeNoOp - case *ahptypes.ChangesetFilesReviewedChangedAction: - ids := make(map[string]struct{}, len(a.FileIds)) - for _, id := range a.FileIds { + case *ahptypes.ChangesetFilesReviewChangedAction: + ids := make(map[string]struct{}, len(a.Files)) + for _, id := range a.Files { ids[id] = struct{}{} } changed := false diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index f5f61b18..99760ccc 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -74,7 +74,7 @@ const ( ActionTypeChangesetStatusChanged ActionType = "changeset/statusChanged" ActionTypeChangesetFileSet ActionType = "changeset/fileSet" ActionTypeChangesetFileRemoved ActionType = "changeset/fileRemoved" - ActionTypeChangesetFilesReviewedChanged ActionType = "changeset/filesReviewedChanged" + ActionTypeChangesetFilesReviewChanged ActionType = "changeset/filesReviewChanged" ActionTypeChangesetContentChanged ActionType = "changeset/contentChanged" ActionTypeChangesetOperationsChanged ActionType = "changeset/operationsChanged" ActionTypeChangesetOperationStatusChanged ActionType = "changeset/operationStatusChanged" @@ -993,21 +993,32 @@ type ChangesetFileRemovedAction struct { FileId string `json:"fileId"` } -// Update the {@link ChangesetFile.reviewed} flag for one or more files, -// identified by their {@link ChangesetFile.id}. -// -// Dispatched by the server as the user marks files reviewed or unreviewed -// (e.g. toggling a single file, or a "mark all as reviewed" affordance). -// Only servers that support the "review" functionality dispatch this; a -// server that leaves {@link ChangesetFile.reviewed} `undefined` never does. -// -// The reducer sets `reviewed` on every matching file and ignores any -// `fileIds` entry that does not correspond to a current file. -type ChangesetFilesReviewedChangedAction struct { - Type ActionType `json:"type"` - // The {@link ChangesetFile.id}s whose reviewed state changed. - FileIds []string `json:"fileIds"` - // The new reviewed state to apply to each listed file. +// Set the {@link ChangesetFile.reviewed} flag for one or more files — the +// GitHub-style "Viewed" toggle, applied in a single batch. +// +// Targets files by their {@link ChangesetFile.id}. Ids in {@link files} that +// do not match a file currently present in the changeset are ignored; if none +// match, the action is a no-op. Only the {@link ChangesetFile.reviewed} field +// of each matched file is affected; the files' {@link ChangesetFile.edit | edit} +// and {@link ChangesetFile._meta | _meta} are left untouched. +// +// Only meaningful on a changeset that advertises +// {@link ChangesetCapabilities.review}. Unlike every other `changeset/*` action +// this one is **client-dispatchable**: a reviewer toggles review state directly, +// applying it optimistically through the write-ahead reducer and letting the +// server echo it back on the normal `action` envelope stream. The server MAY +// also originate it (e.g. an agent marking its own output reviewed). +// +// There is no protocol-level content version, so review is not reset +// automatically when a file's contents change under a stable id. The server, +// which is the authority on what changed, resets review explicitly — either by +// re-emitting the file without `reviewed: true`, or by dispatching this action +// with `reviewed: false`. +type ChangesetFilesReviewChangedAction struct { + Type ActionType `json:"type"` + // The {@link ChangesetFile.id | ids} of the files whose review state changed. + Files []string `json:"files"` + // New review state applied to every listed file: `true` once reviewed, `false` to clear it. Reviewed bool `json:"reviewed"` } @@ -1381,7 +1392,7 @@ func (*SessionMetaChangedAction) isStateAction() {} func (*ChangesetStatusChangedAction) isStateAction() {} func (*ChangesetFileSetAction) isStateAction() {} func (*ChangesetFileRemovedAction) isStateAction() {} -func (*ChangesetFilesReviewedChangedAction) isStateAction() {} +func (*ChangesetFilesReviewChangedAction) isStateAction() {} func (*ChangesetContentChangedAction) isStateAction() {} func (*ChangesetOperationsChangedAction) isStateAction() {} func (*ChangesetOperationStatusChangedAction) isStateAction() {} @@ -1755,8 +1766,8 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value - case "changeset/filesReviewedChanged": - var value ChangesetFilesReviewedChangedAction + case "changeset/filesReviewChanged": + var value ChangesetFilesReviewChangedAction if err := json.Unmarshal(data, &value); err != nil { return err } diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index ec45d39a..f54750bb 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -2935,6 +2935,30 @@ type Changeset struct { // Implementations MAY provide additional values; clients SHOULD fall back // to a reasonable default when an unknown value is encountered. ChangeKind string `json:"changeKind"` + // Optional capability declarations for this changeset. Absent (or an empty + // object) means the changeset advertises no optional capabilities. + // + // Because the catalogue entry is delivered up-front on + // {@link ChangesetState | the session's changeset list}, clients can decide + // whether to surface capability-gated UI (such as review checkboxes) without + // first subscribing to the changeset URI. Mirrors the presence-flag + // convention of `ClientCapabilities`. + Capabilities *ChangesetCapabilities `json:"capabilities,omitempty"` +} + +// Optional capabilities a changeset advertises on its catalogue +// {@link Changeset} entry. +// +// Each field is a presence flag: an empty object `{}` means "supported", +// absence means "not supported". Sub-fields on individual capabilities are +// reserved for future per-capability options. +type ChangesetCapabilities struct { + // The changeset supports the per-file **review** workflow. When declared, + // clients MAY surface a GitHub-style "Viewed" toggle per file and dispatch + // {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to + // set each file's {@link ChangesetFile.reviewed} flag. Clients that omit + // handling MUST treat the changeset as non-reviewable. + Review map[string]json.RawMessage `json:"review,omitempty"` } // Full state for a single changeset, returned when a client subscribes to @@ -2964,10 +2988,22 @@ type ChangesetFile struct { // Reuses the existing {@link FileEdit} shape. Clients derive line // additions, deletions, and rename/create/delete semantics from this. Edit FileEdit `json:"edit"` - // Whether the user has reviewed this file. Omit (or set to `undefined`) - // to indicate that the server does not support the "review" functionality; - // in that case clients should not surface any reviewed/unreviewed - // affordance for this file. + // Whether a reviewer has marked this file as reviewed (the GitHub-style + // "Viewed" checkbox). Absent is equivalent to `false` — clients MUST treat + // a missing value as not-yet-reviewed. + // + // Requires the changeset to advertise {@link ChangesetCapabilities.review}. + // Clients toggle it by dispatching + // {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`}; + // the server MAY also originate it (e.g. an agent self-reviewing its own + // output). + // + // There is no content version in the protocol, so review is **not** reset + // automatically when a file's contents change under a stable id. The server, + // which is the authority on what changed, resets review explicitly — either + // by re-emitting the file (via {@link ChangesetFileSetAction} or + // {@link ChangesetContentChangedAction}) without `reviewed: true`, or by + // dispatching `changeset/filesReviewChanged` with `reviewed: false`. Reviewed *bool `json:"reviewed,omitempty"` // Server-defined opaque metadata, surfaced to operations and tooling // but not interpreted by the protocol. diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 5edb26e1..1adf0271 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -1394,8 +1394,8 @@ public fun changesetReducer(state: ChangesetState, action: StateAction): Changes } } - is StateActionChangesetFilesReviewedChanged -> { - val ids = action.value.fileIds.toSet() + is StateActionChangesetFilesReviewChanged -> { + val ids = action.value.files.toSet() var changed = false val next = state.files.map { file -> if (file.id !in ids || file.reviewed == action.value.reviewed) { diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index cd859497..fca5572d 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -136,8 +136,8 @@ enum class ActionType { CHANGESET_FILE_SET, @SerialName("changeset/fileRemoved") CHANGESET_FILE_REMOVED, - @SerialName("changeset/filesReviewedChanged") - CHANGESET_FILES_REVIEWED_CHANGED, + @SerialName("changeset/filesReviewChanged") + CHANGESET_FILES_REVIEW_CHANGED, @SerialName("changeset/contentChanged") CHANGESET_CONTENT_CHANGED, @SerialName("changeset/operationsChanged") @@ -1062,14 +1062,14 @@ data class ChangesetFileRemovedAction( ) @Serializable -data class ChangesetFilesReviewedChangedAction( +data class ChangesetFilesReviewChangedAction( val type: ActionType, /** - * The {@link ChangesetFile.id}s whose reviewed state changed. + * The {@link ChangesetFile.id | ids} of the files whose review state changed. */ - val fileIds: List, + val files: List, /** - * The new reviewed state to apply to each listed file. + * New review state applied to every listed file: `true` once reviewed, `false` to clear it. */ val reviewed: Boolean ) @@ -1458,7 +1458,7 @@ sealed interface StateAction @JvmInline value class StateActionChangesetStatusChanged(val value: ChangesetStatusChangedAction) : StateAction @JvmInline value class StateActionChangesetFileSet(val value: ChangesetFileSetAction) : StateAction @JvmInline value class StateActionChangesetFileRemoved(val value: ChangesetFileRemovedAction) : StateAction -@JvmInline value class StateActionChangesetFilesReviewedChanged(val value: ChangesetFilesReviewedChangedAction) : StateAction +@JvmInline value class StateActionChangesetFilesReviewChanged(val value: ChangesetFilesReviewChangedAction) : StateAction @JvmInline value class StateActionChangesetContentChanged(val value: ChangesetContentChangedAction) : StateAction @JvmInline value class StateActionChangesetOperationsChanged(val value: ChangesetOperationsChangedAction) : StateAction @JvmInline value class StateActionChangesetOperationStatusChanged(val value: ChangesetOperationStatusChangedAction) : StateAction @@ -1552,7 +1552,7 @@ internal object StateActionSerializer : KSerializer { "changeset/statusChanged" -> StateActionChangesetStatusChanged(input.json.decodeFromJsonElement(ChangesetStatusChangedAction.serializer(), element)) "changeset/fileSet" -> StateActionChangesetFileSet(input.json.decodeFromJsonElement(ChangesetFileSetAction.serializer(), element)) "changeset/fileRemoved" -> StateActionChangesetFileRemoved(input.json.decodeFromJsonElement(ChangesetFileRemovedAction.serializer(), element)) - "changeset/filesReviewedChanged" -> StateActionChangesetFilesReviewedChanged(input.json.decodeFromJsonElement(ChangesetFilesReviewedChangedAction.serializer(), element)) + "changeset/filesReviewChanged" -> StateActionChangesetFilesReviewChanged(input.json.decodeFromJsonElement(ChangesetFilesReviewChangedAction.serializer(), element)) "changeset/contentChanged" -> StateActionChangesetContentChanged(input.json.decodeFromJsonElement(ChangesetContentChangedAction.serializer(), element)) "changeset/operationsChanged" -> StateActionChangesetOperationsChanged(input.json.decodeFromJsonElement(ChangesetOperationsChangedAction.serializer(), element)) "changeset/operationStatusChanged" -> StateActionChangesetOperationStatusChanged(input.json.decodeFromJsonElement(ChangesetOperationStatusChangedAction.serializer(), element)) @@ -1639,7 +1639,7 @@ internal object StateActionSerializer : KSerializer { is StateActionChangesetStatusChanged -> output.json.encodeToJsonElement(ChangesetStatusChangedAction.serializer(), value.value) is StateActionChangesetFileSet -> output.json.encodeToJsonElement(ChangesetFileSetAction.serializer(), value.value) is StateActionChangesetFileRemoved -> output.json.encodeToJsonElement(ChangesetFileRemovedAction.serializer(), value.value) - is StateActionChangesetFilesReviewedChanged -> output.json.encodeToJsonElement(ChangesetFilesReviewedChangedAction.serializer(), value.value) + is StateActionChangesetFilesReviewChanged -> output.json.encodeToJsonElement(ChangesetFilesReviewChangedAction.serializer(), value.value) is StateActionChangesetContentChanged -> output.json.encodeToJsonElement(ChangesetContentChangedAction.serializer(), value.value) is StateActionChangesetOperationsChanged -> output.json.encodeToJsonElement(ChangesetOperationsChangedAction.serializer(), value.value) is StateActionChangesetOperationStatusChanged -> output.json.encodeToJsonElement(ChangesetOperationStatusChangedAction.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index c44bc9ad..891002ed 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -3986,7 +3986,30 @@ data class Changeset( * Implementations MAY provide additional values; clients SHOULD fall back * to a reasonable default when an unknown value is encountered. */ - val changeKind: String + val changeKind: String, + /** + * Optional capability declarations for this changeset. Absent (or an empty + * object) means the changeset advertises no optional capabilities. + * + * Because the catalogue entry is delivered up-front on + * {@link ChangesetState | the session's changeset list}, clients can decide + * whether to surface capability-gated UI (such as review checkboxes) without + * first subscribing to the changeset URI. Mirrors the presence-flag + * convention of `ClientCapabilities`. + */ + val capabilities: ChangesetCapabilities? = null +) + +@Serializable +data class ChangesetCapabilities( + /** + * The changeset supports the per-file **review** workflow. When declared, + * clients MAY surface a GitHub-style "Viewed" toggle per file and dispatch + * {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to + * set each file's {@link ChangesetFile.reviewed} flag. Clients that omit + * handling MUST treat the changeset as non-reviewable. + */ + val review: Map? = null ) @Serializable @@ -4023,10 +4046,22 @@ data class ChangesetFile( */ val edit: FileEdit, /** - * Whether the user has reviewed this file. Omit (or set to `undefined`) - * to indicate that the server does not support the "review" functionality; - * in that case clients should not surface any reviewed/unreviewed - * affordance for this file. + * Whether a reviewer has marked this file as reviewed (the GitHub-style + * "Viewed" checkbox). Absent is equivalent to `false` — clients MUST treat + * a missing value as not-yet-reviewed. + * + * Requires the changeset to advertise {@link ChangesetCapabilities.review}. + * Clients toggle it by dispatching + * {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`}; + * the server MAY also originate it (e.g. an agent self-reviewing its own + * output). + * + * There is no content version in the protocol, so review is **not** reset + * automatically when a file's contents change under a stable id. The server, + * which is the authority on what changed, resets review explicitly — either + * by re-emitting the file (via {@link ChangesetFileSetAction} or + * {@link ChangesetContentChangedAction}) without `reviewed: true`, or by + * dispatching `changeset/filesReviewChanged` with `reviewed: false`. */ val reviewed: Boolean? = null, /** diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index e871381c..6d076aaa 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -137,8 +137,8 @@ pub enum ActionType { ChangesetFileSet, #[serde(rename = "changeset/fileRemoved")] ChangesetFileRemoved, - #[serde(rename = "changeset/filesReviewedChanged")] - ChangesetFilesReviewedChanged, + #[serde(rename = "changeset/filesReviewChanged")] + ChangesetFilesReviewChanged, #[serde(rename = "changeset/contentChanged")] ChangesetContentChanged, #[serde(rename = "changeset/operationsChanged")] @@ -1199,22 +1199,33 @@ pub struct ChangesetFileRemovedAction { pub file_id: String, } -/// Update the {@link ChangesetFile.reviewed} flag for one or more files, -/// identified by their {@link ChangesetFile.id}. +/// Set the {@link ChangesetFile.reviewed} flag for one or more files — the +/// GitHub-style "Viewed" toggle, applied in a single batch. /// -/// Dispatched by the server as the user marks files reviewed or unreviewed -/// (e.g. toggling a single file, or a "mark all as reviewed" affordance). -/// Only servers that support the "review" functionality dispatch this; a -/// server that leaves {@link ChangesetFile.reviewed} `undefined` never does. +/// Targets files by their {@link ChangesetFile.id}. Ids in {@link files} that +/// do not match a file currently present in the changeset are ignored; if none +/// match, the action is a no-op. Only the {@link ChangesetFile.reviewed} field +/// of each matched file is affected; the files' {@link ChangesetFile.edit | edit} +/// and {@link ChangesetFile._meta | _meta} are left untouched. /// -/// The reducer sets `reviewed` on every matching file and ignores any -/// `fileIds` entry that does not correspond to a current file. +/// Only meaningful on a changeset that advertises +/// {@link ChangesetCapabilities.review}. Unlike every other `changeset/*` action +/// this one is **client-dispatchable**: a reviewer toggles review state directly, +/// applying it optimistically through the write-ahead reducer and letting the +/// server echo it back on the normal `action` envelope stream. The server MAY +/// also originate it (e.g. an agent marking its own output reviewed). +/// +/// There is no protocol-level content version, so review is not reset +/// automatically when a file's contents change under a stable id. The server, +/// which is the authority on what changed, resets review explicitly — either by +/// re-emitting the file without `reviewed: true`, or by dispatching this action +/// with `reviewed: false`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ChangesetFilesReviewedChangedAction { - /// The {@link ChangesetFile.id}s whose reviewed state changed. - pub file_ids: Vec, - /// The new reviewed state to apply to each listed file. +pub struct ChangesetFilesReviewChangedAction { + /// The {@link ChangesetFile.id | ids} of the files whose review state changed. + pub files: Vec, + /// New review state applied to every listed file: `true` once reviewed, `false` to clear it. pub reviewed: bool, } @@ -1709,8 +1720,8 @@ pub enum StateAction { ChangesetFileSet(ChangesetFileSetAction), #[serde(rename = "changeset/fileRemoved")] ChangesetFileRemoved(ChangesetFileRemovedAction), - #[serde(rename = "changeset/filesReviewedChanged")] - ChangesetFilesReviewedChanged(ChangesetFilesReviewedChangedAction), + #[serde(rename = "changeset/filesReviewChanged")] + ChangesetFilesReviewChanged(ChangesetFilesReviewChangedAction), #[serde(rename = "changeset/contentChanged")] ChangesetContentChanged(Box), #[serde(rename = "changeset/operationsChanged")] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index bee02b04..505c88f8 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -3563,6 +3563,34 @@ pub struct Changeset { /// Implementations MAY provide additional values; clients SHOULD fall back /// to a reasonable default when an unknown value is encountered. pub change_kind: String, + /// Optional capability declarations for this changeset. Absent (or an empty + /// object) means the changeset advertises no optional capabilities. + /// + /// Because the catalogue entry is delivered up-front on + /// {@link ChangesetState | the session's changeset list}, clients can decide + /// whether to surface capability-gated UI (such as review checkboxes) without + /// first subscribing to the changeset URI. Mirrors the presence-flag + /// convention of `ClientCapabilities`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, +} + +/// Optional capabilities a changeset advertises on its catalogue +/// {@link Changeset} entry. +/// +/// Each field is a presence flag: an empty object `{}` means "supported", +/// absence means "not supported". Sub-fields on individual capabilities are +/// reserved for future per-capability options. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ChangesetCapabilities { + /// The changeset supports the per-file **review** workflow. When declared, + /// clients MAY surface a GitHub-style "Viewed" toggle per file and dispatch + /// {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to + /// set each file's {@link ChangesetFile.reviewed} flag. Clients that omit + /// handling MUST treat the changeset as non-reviewable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, } /// Full state for a single changeset, returned when a client subscribes to @@ -3598,10 +3626,22 @@ pub struct ChangesetFile { /// Reuses the existing {@link FileEdit} shape. Clients derive line /// additions, deletions, and rename/create/delete semantics from this. pub edit: FileEdit, - /// Whether the user has reviewed this file. Omit (or set to `undefined`) - /// to indicate that the server does not support the "review" functionality; - /// in that case clients should not surface any reviewed/unreviewed - /// affordance for this file. + /// Whether a reviewer has marked this file as reviewed (the GitHub-style + /// "Viewed" checkbox). Absent is equivalent to `false` — clients MUST treat + /// a missing value as not-yet-reviewed. + /// + /// Requires the changeset to advertise {@link ChangesetCapabilities.review}. + /// Clients toggle it by dispatching + /// {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`}; + /// the server MAY also originate it (e.g. an agent self-reviewing its own + /// output). + /// + /// There is no content version in the protocol, so review is **not** reset + /// automatically when a file's contents change under a stable id. The server, + /// which is the authority on what changed, resets review explicitly — either + /// by re-emitting the file (via {@link ChangesetFileSetAction} or + /// {@link ChangesetContentChangedAction}) without `reviewed: true`, or by + /// dispatching `changeset/filesReviewChanged` with `reviewed: false`. #[serde(default, skip_serializing_if = "Option::is_none")] pub reviewed: Option, /// Server-defined opaque metadata, surfaced to operations and tooling diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 4f854ba9..67c4bf8b 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -1532,8 +1532,8 @@ pub fn apply_action_to_changeset( state.files.remove(idx); ReduceOutcome::Applied } - StateAction::ChangesetFilesReviewedChanged(a) => { - let ids: std::collections::HashSet<&String> = a.file_ids.iter().collect(); + StateAction::ChangesetFilesReviewChanged(a) => { + let ids: std::collections::HashSet<&String> = a.files.iter().collect(); let mut changed = false; for file in state.files.iter_mut() { if !ids.contains(&file.id) || file.reviewed == Some(a.reviewed) { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index edb1dcdc..bb0a311d 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -61,7 +61,7 @@ public enum ActionType: String, Codable, Sendable { case changesetStatusChanged = "changeset/statusChanged" case changesetFileSet = "changeset/fileSet" case changesetFileRemoved = "changeset/fileRemoved" - case changesetFilesReviewedChanged = "changeset/filesReviewedChanged" + case changesetFilesReviewChanged = "changeset/filesReviewChanged" case changesetContentChanged = "changeset/contentChanged" case changesetOperationsChanged = "changeset/operationsChanged" case changesetOperationStatusChanged = "changeset/operationStatusChanged" @@ -1384,20 +1384,20 @@ public struct ChangesetFileRemovedAction: Codable, Sendable { } } -public struct ChangesetFilesReviewedChangedAction: Codable, Sendable { +public struct ChangesetFilesReviewChangedAction: Codable, Sendable { public var type: ActionType - /// The {@link ChangesetFile.id}s whose reviewed state changed. - public var fileIds: [String] - /// The new reviewed state to apply to each listed file. + /// The {@link ChangesetFile.id | ids} of the files whose review state changed. + public var files: [String] + /// New review state applied to every listed file: `true` once reviewed, `false` to clear it. public var reviewed: Bool public init( type: ActionType, - fileIds: [String], + files: [String], reviewed: Bool ) { self.type = type - self.fileIds = fileIds + self.files = files self.reviewed = reviewed } } @@ -1894,7 +1894,7 @@ public enum StateAction: Codable, Sendable { case changesetStatusChanged(ChangesetStatusChangedAction) case changesetFileSet(ChangesetFileSetAction) case changesetFileRemoved(ChangesetFileRemovedAction) - case changesetFilesReviewedChanged(ChangesetFilesReviewedChangedAction) + case changesetFilesReviewChanged(ChangesetFilesReviewChangedAction) case changesetContentChanged(ChangesetContentChangedAction) case changesetOperationsChanged(ChangesetOperationsChangedAction) case changesetOperationStatusChanged(ChangesetOperationStatusChangedAction) @@ -2040,8 +2040,8 @@ public enum StateAction: Codable, Sendable { self = .changesetFileSet(try ChangesetFileSetAction(from: decoder)) case "changeset/fileRemoved": self = .changesetFileRemoved(try ChangesetFileRemovedAction(from: decoder)) - case "changeset/filesReviewedChanged": - self = .changesetFilesReviewedChanged(try ChangesetFilesReviewedChangedAction(from: decoder)) + case "changeset/filesReviewChanged": + self = .changesetFilesReviewChanged(try ChangesetFilesReviewChangedAction(from: decoder)) case "changeset/contentChanged": self = .changesetContentChanged(try ChangesetContentChangedAction(from: decoder)) case "changeset/operationsChanged": @@ -2150,7 +2150,7 @@ public enum StateAction: Codable, Sendable { case .changesetStatusChanged(let v): try v.encode(to: encoder) case .changesetFileSet(let v): try v.encode(to: encoder) case .changesetFileRemoved(let v): try v.encode(to: encoder) - case .changesetFilesReviewedChanged(let v): try v.encode(to: encoder) + case .changesetFilesReviewChanged(let v): try v.encode(to: encoder) case .changesetContentChanged(let v): try v.encode(to: encoder) case .changesetOperationsChanged(let v): try v.encode(to: encoder) case .changesetOperationStatusChanged(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 277d8659..4f12f949 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -4439,17 +4439,43 @@ public struct Changeset: Codable, Sendable { /// Implementations MAY provide additional values; clients SHOULD fall back /// to a reasonable default when an unknown value is encountered. public var changeKind: String + /// Optional capability declarations for this changeset. Absent (or an empty + /// object) means the changeset advertises no optional capabilities. + /// + /// Because the catalogue entry is delivered up-front on + /// {@link ChangesetState | the session's changeset list}, clients can decide + /// whether to surface capability-gated UI (such as review checkboxes) without + /// first subscribing to the changeset URI. Mirrors the presence-flag + /// convention of `ClientCapabilities`. + public var capabilities: ChangesetCapabilities? public init( label: String, uriTemplate: String, description: String? = nil, - changeKind: String + changeKind: String, + capabilities: ChangesetCapabilities? = nil ) { self.label = label self.uriTemplate = uriTemplate self.description = description self.changeKind = changeKind + self.capabilities = capabilities + } +} + +public struct ChangesetCapabilities: Codable, Sendable { + /// The changeset supports the per-file **review** workflow. When declared, + /// clients MAY surface a GitHub-style "Viewed" toggle per file and dispatch + /// {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to + /// set each file's {@link ChangesetFile.reviewed} flag. Clients that omit + /// handling MUST treat the changeset as non-reviewable. + public var review: [String: AnyCodable]? + + public init( + review: [String: AnyCodable]? = nil + ) { + self.review = review } } @@ -4484,10 +4510,22 @@ public struct ChangesetFile: Codable, Sendable { /// Reuses the existing {@link FileEdit} shape. Clients derive line /// additions, deletions, and rename/create/delete semantics from this. public var edit: FileEdit - /// Whether the user has reviewed this file. Omit (or set to `undefined`) - /// to indicate that the server does not support the "review" functionality; - /// in that case clients should not surface any reviewed/unreviewed - /// affordance for this file. + /// Whether a reviewer has marked this file as reviewed (the GitHub-style + /// "Viewed" checkbox). Absent is equivalent to `false` — clients MUST treat + /// a missing value as not-yet-reviewed. + /// + /// Requires the changeset to advertise {@link ChangesetCapabilities.review}. + /// Clients toggle it by dispatching + /// {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`}; + /// the server MAY also originate it (e.g. an agent self-reviewing its own + /// output). + /// + /// There is no content version in the protocol, so review is **not** reset + /// automatically when a file's contents change under a stable id. The server, + /// which is the authority on what changed, resets review explicitly — either + /// by re-emitting the file (via {@link ChangesetFileSetAction} or + /// {@link ChangesetContentChangedAction}) without `reviewed: true`, or by + /// dispatching `changeset/filesReviewChanged` with `reviewed: false`. public var reviewed: Bool? /// Server-defined opaque metadata, surfaced to operations and tooling /// but not interpreted by the protocol. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index f3c955ee..bb74db72 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -1144,8 +1144,8 @@ public func changesetReducer(state: ChangesetState, action: StateAction) -> Chan next.files.remove(at: idx) return next - case .changesetFilesReviewedChanged(let a): - let ids = Set(a.fileIds) + case .changesetFilesReviewChanged(let a): + let ids = Set(a.files) var next = state var changed = false for idx in next.files.indices { diff --git a/docs/.changes/20260709-changeset-review-capability.json b/docs/.changes/20260709-changeset-review-capability.json new file mode 100644 index 00000000..99b86ba7 --- /dev/null +++ b/docs/.changes/20260709-changeset-review-capability.json @@ -0,0 +1,5 @@ +{ + "type": "added", + "message": "`Changeset.capabilities` with a `review` presence flag (`ChangesetCapabilities`) so a changeset can advertise support for the per-file review workflow up-front on the session's changeset list.", + "issues": [328] +} diff --git a/docs/.changes/20260709-changeset-review-client-dispatchable.json b/docs/.changes/20260709-changeset-review-client-dispatchable.json new file mode 100644 index 00000000..a6a4005e --- /dev/null +++ b/docs/.changes/20260709-changeset-review-client-dispatchable.json @@ -0,0 +1,5 @@ +{ + "type": "changed", + "message": "Renamed the changeset review action `changeset/filesReviewedChanged` to `changeset/filesReviewChanged` (field `fileIds` → `files`) and made it client-dispatchable, so a reviewer can toggle a file's `reviewed` flag directly through the write-ahead reducer.", + "issues": [328] +} diff --git a/docs/guide/changesets.md b/docs/guide/changesets.md index 34ec88cc..9d380073 100644 --- a/docs/guide/changesets.md +++ b/docs/guide/changesets.md @@ -33,6 +33,11 @@ Changeset { * `'turn'`, or `'compare-turns'`. Other values allowed. */ changeKind: string + /** Optional capability declarations (presence-flag objects). */ + capabilities?: { + /** Present ⇒ this changeset supports the per-file review workflow. */ + review?: {} + } } ``` @@ -65,7 +70,7 @@ ChangesetState { ChangesetFile { id: string // typically `after.uri` (or `before.uri` for deletions) edit: FileEdit // reuses the existing FileEdit shape - reviewed?: boolean // omit when the server has no "review" support + reviewed?: boolean // GitHub-style "Viewed" flag; absent ⇒ not reviewed _meta?: Record } ``` @@ -78,12 +83,53 @@ of the changeset URI: | `changeset/statusChanged` | No | `status` transitioned (e.g. `computing → ready`). | | `changeset/fileSet` | No | Upsert a `ChangesetFile` (new or replacing existing by `id`). | | `changeset/fileRemoved` | No | A file is no longer in the changeset. | -| `changeset/filesReviewedChanged` | No | The `reviewed` flag for one or more files changed (servers with review support). | +| `changeset/filesReviewChanged` | Yes | A reviewer toggled the `reviewed` flag on one or more files. | | `changeset/contentChanged` | No | Full replacement of files, optionally with operations or error details. | | `changeset/operationsChanged` | No | The set of available `operations` changed. | | `changeset/operationStatusChanged` | No | A single operation's `status` transitioned (e.g. `idle → running → error`). | | `changeset/cleared` | No | All files dropped (e.g. branch switched, or the owning session ended). | +### File Review + +**Review is a capability of the changeset.** A changeset advertises support for +the review workflow on its catalogue `Changeset` entry via +`capabilities.review` (a presence-flag object). Clients see this up-front on the +session's changeset list, so they can decide whether to surface review UI +without first subscribing. When the capability is absent, the changeset is not +reviewable. + +For a reviewable changeset, each `ChangesetFile` carries an optional `reviewed` +flag — the equivalent of GitHub's per-file **"Viewed"** checkbox. A missing +value is treated as **not reviewed**. + +Unlike the rest of the `changeset/*` family, the +`changeset/filesReviewChanged` action is **client-dispatchable**: a reviewer +toggles files' review state directly, applying it optimistically through the +write-ahead reducer and letting the server echo it back on the normal `action` +envelope stream. The server MAY also originate it (e.g. an agent marking its own +output reviewed). The action is **batched** — it carries a list of file ids that +all move to the same `reviewed` value. + +```typescript +// dispatched by a client (or the server) +{ + type: 'changeset/filesReviewChanged' + files: string[] // ChangesetFile.id values + reviewed: boolean // true marks the files reviewed, false clears them +} +``` + +The reducer sets `reviewed` on every listed file that is present in the +changeset, leaving each file's `edit` and `_meta` untouched. Ids that don't +match a current file are ignored; the action is a no-op when none match. + +**Reset on edit.** The protocol has no per-file content version, so review is +**not** reset automatically when a file's contents change under a stable id. The +server, which is the authority on what changed, resets review explicitly — +either by re-emitting the file (via `changeset/fileSet` or +`changeset/contentChanged`) without `reviewed: true`, or by dispatching +`changeset/filesReviewChanged` with `reviewed: false`. + ### Changeset Operations A **changeset operation** is a server-declared invokable verb the client diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 65ab0c2d..a2e2d23a 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1675,28 +1675,28 @@ "fileId" ] }, - "ChangesetFilesReviewedChangedAction": { + "ChangesetFilesReviewChangedAction": { "type": "object", - "description": "Update the {@link ChangesetFile.reviewed} flag for one or more files,\nidentified by their {@link ChangesetFile.id}.\n\nDispatched by the server as the user marks files reviewed or unreviewed\n(e.g. toggling a single file, or a \"mark all as reviewed\" affordance).\nOnly servers that support the \"review\" functionality dispatch this; a\nserver that leaves {@link ChangesetFile.reviewed} `undefined` never does.\n\nThe reducer sets `reviewed` on every matching file and ignores any\n`fileIds` entry that does not correspond to a current file.", + "description": "Set the {@link ChangesetFile.reviewed} flag for one or more files — the\nGitHub-style \"Viewed\" toggle, applied in a single batch.\n\nTargets files by their {@link ChangesetFile.id}. Ids in {@link files} that\ndo not match a file currently present in the changeset are ignored; if none\nmatch, the action is a no-op. Only the {@link ChangesetFile.reviewed} field\nof each matched file is affected; the files' {@link ChangesetFile.edit | edit}\nand {@link ChangesetFile._meta | _meta} are left untouched.\n\nOnly meaningful on a changeset that advertises\n{@link ChangesetCapabilities.review}. Unlike every other `changeset/*` action\nthis one is **client-dispatchable**: a reviewer toggles review state directly,\napplying it optimistically through the write-ahead reducer and letting the\nserver echo it back on the normal `action` envelope stream. The server MAY\nalso originate it (e.g. an agent marking its own output reviewed).\n\nThere is no protocol-level content version, so review is not reset\nautomatically when a file's contents change under a stable id. The server,\nwhich is the authority on what changed, resets review explicitly — either by\nre-emitting the file without `reviewed: true`, or by dispatching this action\nwith `reviewed: false`.", "properties": { "type": { - "const": "changeset/filesReviewedChanged" + "const": "changeset/filesReviewChanged" }, - "fileIds": { + "files": { "type": "array", "items": { "type": "string" }, - "description": "The {@link ChangesetFile.id}s whose reviewed state changed." + "description": "The {@link ChangesetFile.id | ids} of the files whose review state changed." }, "reviewed": { "type": "boolean", - "description": "The new reviewed state to apply to each listed file." + "description": "New review state applied to every listed file: `true` once reviewed, `false` to clear it." } }, "required": [ "type", - "fileIds", + "files", "reviewed" ] }, @@ -6242,6 +6242,10 @@ "changeKind": { "type": "string", "description": "Advisory hint describing what kind of changeset this is, so clients can\ngroup, sort, or render an appropriate icon without parsing\n{@link uriTemplate}. Recognized values include:\n\n- `'session'`: a static, session-wide changeset covering all changes the\n agent has produced in this session.\n- `'branch'`: changes relative to a base branch (e.g. a feature branch\n diffed against `main`).\n- `'uncommitted'`: the workspace's current uncommitted changes.\n- `'turn'`: changes produced by a single turn. Typically paired with a\n `{turnId}` variable in {@link uriTemplate}.\n- `'compare-turns'`: a diff between two turns. Typically paired with\n `{originalTurnId}` and `{modifiedTurnId}` variables in\n {@link uriTemplate}.\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "capabilities": { + "$ref": "#/$defs/ChangesetCapabilities", + "description": "Optional capability declarations for this changeset. Absent (or an empty\nobject) means the changeset advertises no optional capabilities.\n\nBecause the catalogue entry is delivered up-front on\n{@link ChangesetState | the session's changeset list}, clients can decide\nwhether to surface capability-gated UI (such as review checkboxes) without\nfirst subscribing to the changeset URI. Mirrors the presence-flag\nconvention of `ClientCapabilities`." } }, "required": [ @@ -6250,6 +6254,17 @@ "changeKind" ] }, + "ChangesetCapabilities": { + "type": "object", + "description": "Optional capabilities a changeset advertises on its catalogue\n{@link Changeset} entry.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities are\nreserved for future per-capability options.", + "properties": { + "review": { + "type": "object", + "additionalProperties": {}, + "description": "The changeset supports the per-file **review** workflow. When declared,\nclients MAY surface a GitHub-style \"Viewed\" toggle per file and dispatch\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to\nset each file's {@link ChangesetFile.reviewed} flag. Clients that omit\nhandling MUST treat the changeset as non-reviewable." + } + } + }, "ChangesetState": { "type": "object", "description": "Full state for a single changeset, returned when a client subscribes to\nan expanded changeset URI.\n\nThe client already knows the URI it subscribed to, so this state does\nnot redundantly carry it (or the catalogue's `id`, `label`, etc.).\nAggregate counts (`additions`, `deletions`, `files`) are likewise\nomitted: clients trivially compute them from `files[].edit.diff`.", @@ -6296,7 +6311,7 @@ }, "reviewed": { "type": "boolean", - "description": "Whether the user has reviewed this file. Omit (or set to `undefined`)\nto indicate that the server does not support the \"review\" functionality;\nin that case clients should not surface any reviewed/unreviewed\naffordance for this file." + "description": "Whether a reviewer has marked this file as reviewed (the GitHub-style\n\"Viewed\" checkbox). Absent is equivalent to `false` — clients MUST treat\na missing value as not-yet-reviewed.\n\nRequires the changeset to advertise {@link ChangesetCapabilities.review}.\nClients toggle it by dispatching\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`};\nthe server MAY also originate it (e.g. an agent self-reviewing its own\noutput).\n\nThere is no content version in the protocol, so review is **not** reset\nautomatically when a file's contents change under a stable id. The server,\nwhich is the authority on what changed, resets review explicitly — either\nby re-emitting the file (via {@link ChangesetFileSetAction} or\n{@link ChangesetContentChangedAction}) without `reviewed: true`, or by\ndispatching `changeset/filesReviewChanged` with `reviewed: false`." }, "_meta": { "type": "object", @@ -7105,7 +7120,7 @@ "$ref": "#/$defs/ChangesetFileRemovedAction" }, { - "$ref": "#/$defs/ChangesetFilesReviewedChangedAction" + "$ref": "#/$defs/ChangesetFilesReviewChangedAction" }, { "$ref": "#/$defs/ChangesetContentChangedAction" diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 4d28f218..4339802f 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -5530,6 +5530,10 @@ "changeKind": { "type": "string", "description": "Advisory hint describing what kind of changeset this is, so clients can\ngroup, sort, or render an appropriate icon without parsing\n{@link uriTemplate}. Recognized values include:\n\n- `'session'`: a static, session-wide changeset covering all changes the\n agent has produced in this session.\n- `'branch'`: changes relative to a base branch (e.g. a feature branch\n diffed against `main`).\n- `'uncommitted'`: the workspace's current uncommitted changes.\n- `'turn'`: changes produced by a single turn. Typically paired with a\n `{turnId}` variable in {@link uriTemplate}.\n- `'compare-turns'`: a diff between two turns. Typically paired with\n `{originalTurnId}` and `{modifiedTurnId}` variables in\n {@link uriTemplate}.\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "capabilities": { + "$ref": "#/$defs/ChangesetCapabilities", + "description": "Optional capability declarations for this changeset. Absent (or an empty\nobject) means the changeset advertises no optional capabilities.\n\nBecause the catalogue entry is delivered up-front on\n{@link ChangesetState | the session's changeset list}, clients can decide\nwhether to surface capability-gated UI (such as review checkboxes) without\nfirst subscribing to the changeset URI. Mirrors the presence-flag\nconvention of `ClientCapabilities`." } }, "required": [ @@ -5538,6 +5542,17 @@ "changeKind" ] }, + "ChangesetCapabilities": { + "type": "object", + "description": "Optional capabilities a changeset advertises on its catalogue\n{@link Changeset} entry.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities are\nreserved for future per-capability options.", + "properties": { + "review": { + "type": "object", + "additionalProperties": {}, + "description": "The changeset supports the per-file **review** workflow. When declared,\nclients MAY surface a GitHub-style \"Viewed\" toggle per file and dispatch\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to\nset each file's {@link ChangesetFile.reviewed} flag. Clients that omit\nhandling MUST treat the changeset as non-reviewable." + } + } + }, "ChangesetState": { "type": "object", "description": "Full state for a single changeset, returned when a client subscribes to\nan expanded changeset URI.\n\nThe client already knows the URI it subscribed to, so this state does\nnot redundantly carry it (or the catalogue's `id`, `label`, etc.).\nAggregate counts (`additions`, `deletions`, `files`) are likewise\nomitted: clients trivially compute them from `files[].edit.diff`.", @@ -5584,7 +5599,7 @@ }, "reviewed": { "type": "boolean", - "description": "Whether the user has reviewed this file. Omit (or set to `undefined`)\nto indicate that the server does not support the \"review\" functionality;\nin that case clients should not surface any reviewed/unreviewed\naffordance for this file." + "description": "Whether a reviewer has marked this file as reviewed (the GitHub-style\n\"Viewed\" checkbox). Absent is equivalent to `false` — clients MUST treat\na missing value as not-yet-reviewed.\n\nRequires the changeset to advertise {@link ChangesetCapabilities.review}.\nClients toggle it by dispatching\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`};\nthe server MAY also originate it (e.g. an agent self-reviewing its own\noutput).\n\nThere is no content version in the protocol, so review is **not** reset\nautomatically when a file's contents change under a stable id. The server,\nwhich is the authority on what changed, resets review explicitly — either\nby re-emitting the file (via {@link ChangesetFileSetAction} or\n{@link ChangesetContentChangedAction}) without `reviewed: true`, or by\ndispatching `changeset/filesReviewChanged` with `reviewed: false`." }, "_meta": { "type": "object", @@ -7503,28 +7518,28 @@ "fileId" ] }, - "ChangesetFilesReviewedChangedAction": { + "ChangesetFilesReviewChangedAction": { "type": "object", - "description": "Update the {@link ChangesetFile.reviewed} flag for one or more files,\nidentified by their {@link ChangesetFile.id}.\n\nDispatched by the server as the user marks files reviewed or unreviewed\n(e.g. toggling a single file, or a \"mark all as reviewed\" affordance).\nOnly servers that support the \"review\" functionality dispatch this; a\nserver that leaves {@link ChangesetFile.reviewed} `undefined` never does.\n\nThe reducer sets `reviewed` on every matching file and ignores any\n`fileIds` entry that does not correspond to a current file.", + "description": "Set the {@link ChangesetFile.reviewed} flag for one or more files — the\nGitHub-style \"Viewed\" toggle, applied in a single batch.\n\nTargets files by their {@link ChangesetFile.id}. Ids in {@link files} that\ndo not match a file currently present in the changeset are ignored; if none\nmatch, the action is a no-op. Only the {@link ChangesetFile.reviewed} field\nof each matched file is affected; the files' {@link ChangesetFile.edit | edit}\nand {@link ChangesetFile._meta | _meta} are left untouched.\n\nOnly meaningful on a changeset that advertises\n{@link ChangesetCapabilities.review}. Unlike every other `changeset/*` action\nthis one is **client-dispatchable**: a reviewer toggles review state directly,\napplying it optimistically through the write-ahead reducer and letting the\nserver echo it back on the normal `action` envelope stream. The server MAY\nalso originate it (e.g. an agent marking its own output reviewed).\n\nThere is no protocol-level content version, so review is not reset\nautomatically when a file's contents change under a stable id. The server,\nwhich is the authority on what changed, resets review explicitly — either by\nre-emitting the file without `reviewed: true`, or by dispatching this action\nwith `reviewed: false`.", "properties": { "type": { - "const": "changeset/filesReviewedChanged" + "const": "changeset/filesReviewChanged" }, - "fileIds": { + "files": { "type": "array", "items": { "type": "string" }, - "description": "The {@link ChangesetFile.id}s whose reviewed state changed." + "description": "The {@link ChangesetFile.id | ids} of the files whose review state changed." }, "reviewed": { "type": "boolean", - "description": "The new reviewed state to apply to each listed file." + "description": "New review state applied to every listed file: `true` once reviewed, `false` to clear it." } }, "required": [ "type", - "fileIds", + "files", "reviewed" ] }, @@ -7931,7 +7946,7 @@ "$ref": "#/$defs/ChangesetFileRemovedAction" }, { - "$ref": "#/$defs/ChangesetFilesReviewedChangedAction" + "$ref": "#/$defs/ChangesetFilesReviewChangedAction" }, { "$ref": "#/$defs/ChangesetContentChangedAction" diff --git a/schema/errors.schema.json b/schema/errors.schema.json index a35f6122..20345c7b 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -4321,6 +4321,10 @@ "changeKind": { "type": "string", "description": "Advisory hint describing what kind of changeset this is, so clients can\ngroup, sort, or render an appropriate icon without parsing\n{@link uriTemplate}. Recognized values include:\n\n- `'session'`: a static, session-wide changeset covering all changes the\n agent has produced in this session.\n- `'branch'`: changes relative to a base branch (e.g. a feature branch\n diffed against `main`).\n- `'uncommitted'`: the workspace's current uncommitted changes.\n- `'turn'`: changes produced by a single turn. Typically paired with a\n `{turnId}` variable in {@link uriTemplate}.\n- `'compare-turns'`: a diff between two turns. Typically paired with\n `{originalTurnId}` and `{modifiedTurnId}` variables in\n {@link uriTemplate}.\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "capabilities": { + "$ref": "#/$defs/ChangesetCapabilities", + "description": "Optional capability declarations for this changeset. Absent (or an empty\nobject) means the changeset advertises no optional capabilities.\n\nBecause the catalogue entry is delivered up-front on\n{@link ChangesetState | the session's changeset list}, clients can decide\nwhether to surface capability-gated UI (such as review checkboxes) without\nfirst subscribing to the changeset URI. Mirrors the presence-flag\nconvention of `ClientCapabilities`." } }, "required": [ @@ -4329,6 +4333,17 @@ "changeKind" ] }, + "ChangesetCapabilities": { + "type": "object", + "description": "Optional capabilities a changeset advertises on its catalogue\n{@link Changeset} entry.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities are\nreserved for future per-capability options.", + "properties": { + "review": { + "type": "object", + "additionalProperties": {}, + "description": "The changeset supports the per-file **review** workflow. When declared,\nclients MAY surface a GitHub-style \"Viewed\" toggle per file and dispatch\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to\nset each file's {@link ChangesetFile.reviewed} flag. Clients that omit\nhandling MUST treat the changeset as non-reviewable." + } + } + }, "ChangesetState": { "type": "object", "description": "Full state for a single changeset, returned when a client subscribes to\nan expanded changeset URI.\n\nThe client already knows the URI it subscribed to, so this state does\nnot redundantly carry it (or the catalogue's `id`, `label`, etc.).\nAggregate counts (`additions`, `deletions`, `files`) are likewise\nomitted: clients trivially compute them from `files[].edit.diff`.", @@ -4375,7 +4390,7 @@ }, "reviewed": { "type": "boolean", - "description": "Whether the user has reviewed this file. Omit (or set to `undefined`)\nto indicate that the server does not support the \"review\" functionality;\nin that case clients should not surface any reviewed/unreviewed\naffordance for this file." + "description": "Whether a reviewer has marked this file as reviewed (the GitHub-style\n\"Viewed\" checkbox). Absent is equivalent to `false` — clients MUST treat\na missing value as not-yet-reviewed.\n\nRequires the changeset to advertise {@link ChangesetCapabilities.review}.\nClients toggle it by dispatching\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`};\nthe server MAY also originate it (e.g. an agent self-reviewing its own\noutput).\n\nThere is no content version in the protocol, so review is **not** reset\nautomatically when a file's contents change under a stable id. The server,\nwhich is the authority on what changed, resets review explicitly — either\nby re-emitting the file (via {@link ChangesetFileSetAction} or\n{@link ChangesetContentChangedAction}) without `reviewed: true`, or by\ndispatching `changeset/filesReviewChanged` with `reviewed: false`." }, "_meta": { "type": "object", @@ -6649,7 +6664,7 @@ "$ref": "#/$defs/ChangesetFileRemovedAction" }, { - "$ref": "#/$defs/ChangesetFilesReviewedChangedAction" + "$ref": "#/$defs/ChangesetFilesReviewChangedAction" }, { "$ref": "#/$defs/ChangesetContentChangedAction" @@ -8127,28 +8142,28 @@ "fileId" ] }, - "ChangesetFilesReviewedChangedAction": { + "ChangesetFilesReviewChangedAction": { "type": "object", - "description": "Update the {@link ChangesetFile.reviewed} flag for one or more files,\nidentified by their {@link ChangesetFile.id}.\n\nDispatched by the server as the user marks files reviewed or unreviewed\n(e.g. toggling a single file, or a \"mark all as reviewed\" affordance).\nOnly servers that support the \"review\" functionality dispatch this; a\nserver that leaves {@link ChangesetFile.reviewed} `undefined` never does.\n\nThe reducer sets `reviewed` on every matching file and ignores any\n`fileIds` entry that does not correspond to a current file.", + "description": "Set the {@link ChangesetFile.reviewed} flag for one or more files — the\nGitHub-style \"Viewed\" toggle, applied in a single batch.\n\nTargets files by their {@link ChangesetFile.id}. Ids in {@link files} that\ndo not match a file currently present in the changeset are ignored; if none\nmatch, the action is a no-op. Only the {@link ChangesetFile.reviewed} field\nof each matched file is affected; the files' {@link ChangesetFile.edit | edit}\nand {@link ChangesetFile._meta | _meta} are left untouched.\n\nOnly meaningful on a changeset that advertises\n{@link ChangesetCapabilities.review}. Unlike every other `changeset/*` action\nthis one is **client-dispatchable**: a reviewer toggles review state directly,\napplying it optimistically through the write-ahead reducer and letting the\nserver echo it back on the normal `action` envelope stream. The server MAY\nalso originate it (e.g. an agent marking its own output reviewed).\n\nThere is no protocol-level content version, so review is not reset\nautomatically when a file's contents change under a stable id. The server,\nwhich is the authority on what changed, resets review explicitly — either by\nre-emitting the file without `reviewed: true`, or by dispatching this action\nwith `reviewed: false`.", "properties": { "type": { - "const": "changeset/filesReviewedChanged" + "const": "changeset/filesReviewChanged" }, - "fileIds": { + "files": { "type": "array", "items": { "type": "string" }, - "description": "The {@link ChangesetFile.id}s whose reviewed state changed." + "description": "The {@link ChangesetFile.id | ids} of the files whose review state changed." }, "reviewed": { "type": "boolean", - "description": "The new reviewed state to apply to each listed file." + "description": "New review state applied to every listed file: `true` once reviewed, `false` to clear it." } }, "required": [ "type", - "fileIds", + "files", "reviewed" ] }, diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 4bce2189..183dc1e8 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -4481,6 +4481,10 @@ "changeKind": { "type": "string", "description": "Advisory hint describing what kind of changeset this is, so clients can\ngroup, sort, or render an appropriate icon without parsing\n{@link uriTemplate}. Recognized values include:\n\n- `'session'`: a static, session-wide changeset covering all changes the\n agent has produced in this session.\n- `'branch'`: changes relative to a base branch (e.g. a feature branch\n diffed against `main`).\n- `'uncommitted'`: the workspace's current uncommitted changes.\n- `'turn'`: changes produced by a single turn. Typically paired with a\n `{turnId}` variable in {@link uriTemplate}.\n- `'compare-turns'`: a diff between two turns. Typically paired with\n `{originalTurnId}` and `{modifiedTurnId}` variables in\n {@link uriTemplate}.\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "capabilities": { + "$ref": "#/$defs/ChangesetCapabilities", + "description": "Optional capability declarations for this changeset. Absent (or an empty\nobject) means the changeset advertises no optional capabilities.\n\nBecause the catalogue entry is delivered up-front on\n{@link ChangesetState | the session's changeset list}, clients can decide\nwhether to surface capability-gated UI (such as review checkboxes) without\nfirst subscribing to the changeset URI. Mirrors the presence-flag\nconvention of `ClientCapabilities`." } }, "required": [ @@ -4489,6 +4493,17 @@ "changeKind" ] }, + "ChangesetCapabilities": { + "type": "object", + "description": "Optional capabilities a changeset advertises on its catalogue\n{@link Changeset} entry.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities are\nreserved for future per-capability options.", + "properties": { + "review": { + "type": "object", + "additionalProperties": {}, + "description": "The changeset supports the per-file **review** workflow. When declared,\nclients MAY surface a GitHub-style \"Viewed\" toggle per file and dispatch\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to\nset each file's {@link ChangesetFile.reviewed} flag. Clients that omit\nhandling MUST treat the changeset as non-reviewable." + } + } + }, "ChangesetState": { "type": "object", "description": "Full state for a single changeset, returned when a client subscribes to\nan expanded changeset URI.\n\nThe client already knows the URI it subscribed to, so this state does\nnot redundantly carry it (or the catalogue's `id`, `label`, etc.).\nAggregate counts (`additions`, `deletions`, `files`) are likewise\nomitted: clients trivially compute them from `files[].edit.diff`.", @@ -4535,7 +4550,7 @@ }, "reviewed": { "type": "boolean", - "description": "Whether the user has reviewed this file. Omit (or set to `undefined`)\nto indicate that the server does not support the \"review\" functionality;\nin that case clients should not surface any reviewed/unreviewed\naffordance for this file." + "description": "Whether a reviewer has marked this file as reviewed (the GitHub-style\n\"Viewed\" checkbox). Absent is equivalent to `false` — clients MUST treat\na missing value as not-yet-reviewed.\n\nRequires the changeset to advertise {@link ChangesetCapabilities.review}.\nClients toggle it by dispatching\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`};\nthe server MAY also originate it (e.g. an agent self-reviewing its own\noutput).\n\nThere is no content version in the protocol, so review is **not** reset\nautomatically when a file's contents change under a stable id. The server,\nwhich is the authority on what changed, resets review explicitly — either\nby re-emitting the file (via {@link ChangesetFileSetAction} or\n{@link ChangesetContentChangedAction}) without `reviewed: true`, or by\ndispatching `changeset/filesReviewChanged` with `reviewed: false`." }, "_meta": { "type": "object", diff --git a/schema/state.schema.json b/schema/state.schema.json index 14045ce1..ee18eabd 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -4232,6 +4232,10 @@ "changeKind": { "type": "string", "description": "Advisory hint describing what kind of changeset this is, so clients can\ngroup, sort, or render an appropriate icon without parsing\n{@link uriTemplate}. Recognized values include:\n\n- `'session'`: a static, session-wide changeset covering all changes the\n agent has produced in this session.\n- `'branch'`: changes relative to a base branch (e.g. a feature branch\n diffed against `main`).\n- `'uncommitted'`: the workspace's current uncommitted changes.\n- `'turn'`: changes produced by a single turn. Typically paired with a\n `{turnId}` variable in {@link uriTemplate}.\n- `'compare-turns'`: a diff between two turns. Typically paired with\n `{originalTurnId}` and `{modifiedTurnId}` variables in\n {@link uriTemplate}.\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "capabilities": { + "$ref": "#/$defs/ChangesetCapabilities", + "description": "Optional capability declarations for this changeset. Absent (or an empty\nobject) means the changeset advertises no optional capabilities.\n\nBecause the catalogue entry is delivered up-front on\n{@link ChangesetState | the session's changeset list}, clients can decide\nwhether to surface capability-gated UI (such as review checkboxes) without\nfirst subscribing to the changeset URI. Mirrors the presence-flag\nconvention of `ClientCapabilities`." } }, "required": [ @@ -4240,6 +4244,17 @@ "changeKind" ] }, + "ChangesetCapabilities": { + "type": "object", + "description": "Optional capabilities a changeset advertises on its catalogue\n{@link Changeset} entry.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities are\nreserved for future per-capability options.", + "properties": { + "review": { + "type": "object", + "additionalProperties": {}, + "description": "The changeset supports the per-file **review** workflow. When declared,\nclients MAY surface a GitHub-style \"Viewed\" toggle per file and dispatch\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to\nset each file's {@link ChangesetFile.reviewed} flag. Clients that omit\nhandling MUST treat the changeset as non-reviewable." + } + } + }, "ChangesetState": { "type": "object", "description": "Full state for a single changeset, returned when a client subscribes to\nan expanded changeset URI.\n\nThe client already knows the URI it subscribed to, so this state does\nnot redundantly carry it (or the catalogue's `id`, `label`, etc.).\nAggregate counts (`additions`, `deletions`, `files`) are likewise\nomitted: clients trivially compute them from `files[].edit.diff`.", @@ -4286,7 +4301,7 @@ }, "reviewed": { "type": "boolean", - "description": "Whether the user has reviewed this file. Omit (or set to `undefined`)\nto indicate that the server does not support the \"review\" functionality;\nin that case clients should not surface any reviewed/unreviewed\naffordance for this file." + "description": "Whether a reviewer has marked this file as reviewed (the GitHub-style\n\"Viewed\" checkbox). Absent is equivalent to `false` — clients MUST treat\na missing value as not-yet-reviewed.\n\nRequires the changeset to advertise {@link ChangesetCapabilities.review}.\nClients toggle it by dispatching\n{@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`};\nthe server MAY also originate it (e.g. an agent self-reviewing its own\noutput).\n\nThere is no content version in the protocol, so review is **not** reset\nautomatically when a file's contents change under a stable id. The server,\nwhich is the authority on what changed, resets review explicitly — either\nby re-emitting the file (via {@link ChangesetFileSetAction} or\n{@link ChangesetContentChangedAction}) without `reviewed: true`, or by\ndispatching `changeset/filesReviewChanged` with `reviewed: false`." }, "_meta": { "type": "object", diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index f2083210..19176699 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -760,6 +760,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ErrorInfo' }, { name: 'Snapshot' }, { name: 'Changeset' }, + { name: 'ChangesetCapabilities' }, { name: 'ChangesetState' }, { name: 'ChangesetFile' }, { name: 'ChangesetOperation' }, @@ -1299,7 +1300,7 @@ const ACTION_VARIANTS: { { type: 'changeset/statusChanged', variantName: 'ChangesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, { type: 'changeset/fileSet', variantName: 'ChangesetFileSet', tsInterface: 'ChangesetFileSetAction' }, { type: 'changeset/fileRemoved', variantName: 'ChangesetFileRemoved', tsInterface: 'ChangesetFileRemovedAction' }, - { type: 'changeset/filesReviewedChanged', variantName: 'ChangesetFilesReviewedChanged', tsInterface: 'ChangesetFilesReviewedChangedAction' }, + { type: 'changeset/filesReviewChanged', variantName: 'ChangesetFilesReviewChanged', tsInterface: 'ChangesetFilesReviewChangedAction' }, { type: 'changeset/contentChanged', variantName: 'ChangesetContentChanged', tsInterface: 'ChangesetContentChangedAction' }, { type: 'changeset/operationsChanged', variantName: 'ChangesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, { type: 'changeset/operationStatusChanged', variantName: 'ChangesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index f554b605..9ab6b405 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -843,7 +843,7 @@ const STATE_STRUCTS = [ 'TerminalClientClaim', 'TerminalSessionClaim', 'TerminalState', 'TerminalUnclassifiedPart', 'TerminalCommandPart', 'UsageInfo', 'ErrorInfo', 'Snapshot', - 'Changeset', 'ChangesetState', 'ChangesetFile', 'ChangesetOperation', + 'Changeset', 'ChangesetCapabilities', 'ChangesetState', 'ChangesetFile', 'ChangesetOperation', 'AnnotationsSummary', 'AnnotationsState', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', 'ResourceWatchState', 'ResourceChange', @@ -1221,7 +1221,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'changeset/statusChanged', caseName: 'ChangesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, { type: 'changeset/fileSet', caseName: 'ChangesetFileSet', tsInterface: 'ChangesetFileSetAction' }, { type: 'changeset/fileRemoved', caseName: 'ChangesetFileRemoved', tsInterface: 'ChangesetFileRemovedAction' }, - { type: 'changeset/filesReviewedChanged', caseName: 'ChangesetFilesReviewedChanged', tsInterface: 'ChangesetFilesReviewedChangedAction' }, + { type: 'changeset/filesReviewChanged', caseName: 'ChangesetFilesReviewChanged', tsInterface: 'ChangesetFilesReviewChangedAction' }, { type: 'changeset/contentChanged', caseName: 'ChangesetContentChanged', tsInterface: 'ChangesetContentChangedAction' }, { type: 'changeset/operationsChanged', caseName: 'ChangesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, { type: 'changeset/operationStatusChanged', caseName: 'ChangesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index c870f303..d29e56f1 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -791,6 +791,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ErrorInfo' }, { name: 'Snapshot' }, { name: 'Changeset' }, + { name: 'ChangesetCapabilities' }, { name: 'ChangesetState' }, { name: 'ChangesetFile' }, { name: 'ChangesetOperation' }, @@ -1212,7 +1213,7 @@ const ACTION_VARIANTS: { { type: 'changeset/statusChanged', variantName: 'ChangesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, { type: 'changeset/fileSet', variantName: 'ChangesetFileSet', tsInterface: 'ChangesetFileSetAction' }, { type: 'changeset/fileRemoved', variantName: 'ChangesetFileRemoved', tsInterface: 'ChangesetFileRemovedAction' }, - { type: 'changeset/filesReviewedChanged', variantName: 'ChangesetFilesReviewedChanged', tsInterface: 'ChangesetFilesReviewedChangedAction' }, + { type: 'changeset/filesReviewChanged', variantName: 'ChangesetFilesReviewChanged', tsInterface: 'ChangesetFilesReviewChangedAction' }, { type: 'changeset/contentChanged', variantName: 'ChangesetContentChanged', tsInterface: 'ChangesetContentChangedAction', boxed: true }, { type: 'changeset/operationsChanged', variantName: 'ChangesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, { type: 'changeset/operationStatusChanged', variantName: 'ChangesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index ec97c729..d168d1e8 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -593,7 +593,7 @@ const STATE_STRUCTS = [ 'TerminalClientClaim', 'TerminalSessionClaim', 'TerminalState', 'TerminalUnclassifiedPart', 'TerminalCommandPart', 'UsageInfo', 'ErrorInfo', 'Snapshot', - 'Changeset', 'ChangesetState', 'ChangesetFile', 'ChangesetOperation', + 'Changeset', 'ChangesetCapabilities', 'ChangesetState', 'ChangesetFile', 'ChangesetOperation', 'AnnotationsSummary', 'AnnotationsState', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', 'ResourceWatchState', 'ResourceChange', @@ -1130,7 +1130,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'changeset/statusChanged', caseName: 'changesetStatusChanged', tsInterface: 'ChangesetStatusChangedAction' }, { type: 'changeset/fileSet', caseName: 'changesetFileSet', tsInterface: 'ChangesetFileSetAction' }, { type: 'changeset/fileRemoved', caseName: 'changesetFileRemoved', tsInterface: 'ChangesetFileRemovedAction' }, - { type: 'changeset/filesReviewedChanged', caseName: 'changesetFilesReviewedChanged', tsInterface: 'ChangesetFilesReviewedChangedAction' }, + { type: 'changeset/filesReviewChanged', caseName: 'changesetFilesReviewChanged', tsInterface: 'ChangesetFilesReviewChangedAction' }, { type: 'changeset/contentChanged', caseName: 'changesetContentChanged', tsInterface: 'ChangesetContentChangedAction' }, { type: 'changeset/operationsChanged', caseName: 'changesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, { type: 'changeset/operationStatusChanged', caseName: 'changesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 2f4897aa..e5a6179a 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -60,7 +60,7 @@ import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, - ChangesetFilesReviewedChangedAction, + ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, @@ -271,7 +271,7 @@ export type ChangesetAction = | ChangesetStatusChangedAction | ChangesetFileSetAction | ChangesetFileRemovedAction - | ChangesetFilesReviewedChangedAction + | ChangesetFilesReviewChangedAction | ChangesetContentChangedAction | ChangesetOperationsChangedAction | ChangesetOperationStatusChangedAction @@ -280,7 +280,7 @@ export type ChangesetAction = /** Union of changeset actions that clients may dispatch. */ export type ClientChangesetAction = - never + | ChangesetFilesReviewChangedAction ; /** Union of changeset actions that only the server may produce. */ @@ -288,7 +288,6 @@ export type ServerChangesetAction = | ChangesetStatusChangedAction | ChangesetFileSetAction | ChangesetFileRemovedAction - | ChangesetFilesReviewedChangedAction | ChangesetContentChangedAction | ChangesetOperationsChangedAction | ChangesetOperationStatusChangedAction @@ -397,7 +396,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChangesetStatusChanged]: false, [ActionType.ChangesetFileSet]: false, [ActionType.ChangesetFileRemoved]: false, - [ActionType.ChangesetFilesReviewedChanged]: false, + [ActionType.ChangesetFilesReviewChanged]: true, [ActionType.ChangesetContentChanged]: false, [ActionType.ChangesetOperationsChanged]: false, [ActionType.ChangesetOperationStatusChanged]: false, diff --git a/types/channels-changeset/actions.ts b/types/channels-changeset/actions.ts index c9c44101..ff8f4145 100644 --- a/types/channels-changeset/actions.ts +++ b/types/channels-changeset/actions.ts @@ -58,25 +58,37 @@ export interface ChangesetFileRemovedAction { } /** - * Update the {@link ChangesetFile.reviewed} flag for one or more files, - * identified by their {@link ChangesetFile.id}. - * - * Dispatched by the server as the user marks files reviewed or unreviewed - * (e.g. toggling a single file, or a "mark all as reviewed" affordance). - * Only servers that support the "review" functionality dispatch this; a - * server that leaves {@link ChangesetFile.reviewed} `undefined` never does. - * - * The reducer sets `reviewed` on every matching file and ignores any - * `fileIds` entry that does not correspond to a current file. + * Set the {@link ChangesetFile.reviewed} flag for one or more files — the + * GitHub-style "Viewed" toggle, applied in a single batch. + * + * Targets files by their {@link ChangesetFile.id}. Ids in {@link files} that + * do not match a file currently present in the changeset are ignored; if none + * match, the action is a no-op. Only the {@link ChangesetFile.reviewed} field + * of each matched file is affected; the files' {@link ChangesetFile.edit | edit} + * and {@link ChangesetFile._meta | _meta} are left untouched. + * + * Only meaningful on a changeset that advertises + * {@link ChangesetCapabilities.review}. Unlike every other `changeset/*` action + * this one is **client-dispatchable**: a reviewer toggles review state directly, + * applying it optimistically through the write-ahead reducer and letting the + * server echo it back on the normal `action` envelope stream. The server MAY + * also originate it (e.g. an agent marking its own output reviewed). + * + * There is no protocol-level content version, so review is not reset + * automatically when a file's contents change under a stable id. The server, + * which is the authority on what changed, resets review explicitly — either by + * re-emitting the file without `reviewed: true`, or by dispatching this action + * with `reviewed: false`. * * @category Changeset Actions * @version 1 + * @clientDispatchable */ -export interface ChangesetFilesReviewedChangedAction { - type: ActionType.ChangesetFilesReviewedChanged; - /** The {@link ChangesetFile.id}s whose reviewed state changed. */ - fileIds: string[]; - /** The new reviewed state to apply to each listed file. */ +export interface ChangesetFilesReviewChangedAction { + type: ActionType.ChangesetFilesReviewChanged; + /** The {@link ChangesetFile.id | ids} of the files whose review state changed. */ + files: string[]; + /** New review state applied to every listed file: `true` once reviewed, `false` to clear it. */ reviewed: boolean; } diff --git a/types/channels-changeset/reducer.ts b/types/channels-changeset/reducer.ts index f64bf65a..dddbca44 100644 --- a/types/channels-changeset/reducer.ts +++ b/types/channels-changeset/reducer.ts @@ -14,10 +14,11 @@ import { softAssertNever } from '../common/reducer-helpers.js'; * Pure reducer for changeset state. Handles all {@link ChangesetAction} * variants. * - * Per the spec, every changeset action is server-only; the reducer - * preserves a stable file order by appending new files via - * {@link ActionType.ChangesetFileSet} when the id is unknown, and - * replacing in place when it matches an existing entry. + * The reducer preserves a stable file order by appending new files via + * {@link ActionType.ChangesetFileSet} when the id is unknown, and replacing in + * place when it matches an existing entry. Per-file review lives on + * {@link ChangesetFile.reviewed} and is toggled (per file, in batches) by the + * client-dispatchable {@link ActionType.ChangesetFilesReviewChanged}. */ export function changesetReducer(state: ChangesetState, action: ChangesetAction, log?: (msg: string) => void): ChangesetState { switch (action.type) { @@ -51,9 +52,9 @@ export function changesetReducer(state: ChangesetState, action: ChangesetAction, return { ...state, files: next }; } - case ActionType.ChangesetFilesReviewedChanged: { + case ActionType.ChangesetFilesReviewChanged: { let changed = false; - const ids = new Set(action.fileIds); + const ids = new Set(action.files); const next: ChangesetFile[] = state.files.map(f => { if (!ids.has(f.id) || f.reviewed === action.reviewed) { return f; diff --git a/types/channels-changeset/state.ts b/types/channels-changeset/state.ts index a8bd9b83..58bd16d8 100644 --- a/types/channels-changeset/state.ts +++ b/types/channels-changeset/state.ts @@ -63,6 +63,38 @@ export interface Changeset { * to a reasonable default when an unknown value is encountered. */ changeKind: string; + /** + * Optional capability declarations for this changeset. Absent (or an empty + * object) means the changeset advertises no optional capabilities. + * + * Because the catalogue entry is delivered up-front on + * {@link ChangesetState | the session's changeset list}, clients can decide + * whether to surface capability-gated UI (such as review checkboxes) without + * first subscribing to the changeset URI. Mirrors the presence-flag + * convention of `ClientCapabilities`. + */ + capabilities?: ChangesetCapabilities; +} + +/** + * Optional capabilities a changeset advertises on its catalogue + * {@link Changeset} entry. + * + * Each field is a presence flag: an empty object `{}` means "supported", + * absence means "not supported". Sub-fields on individual capabilities are + * reserved for future per-capability options. + * + * @category Changesets + */ +export interface ChangesetCapabilities { + /** + * The changeset supports the per-file **review** workflow. When declared, + * clients MAY surface a GitHub-style "Viewed" toggle per file and dispatch + * {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`} to + * set each file's {@link ChangesetFile.reviewed} flag. Clients that omit + * handling MUST treat the changeset as non-reviewable. + */ + review?: Record; } /** @@ -124,10 +156,22 @@ export interface ChangesetFile { */ edit: FileEdit; /** - * Whether the user has reviewed this file. Omit (or set to `undefined`) - * to indicate that the server does not support the "review" functionality; - * in that case clients should not surface any reviewed/unreviewed - * affordance for this file. + * Whether a reviewer has marked this file as reviewed (the GitHub-style + * "Viewed" checkbox). Absent is equivalent to `false` — clients MUST treat + * a missing value as not-yet-reviewed. + * + * Requires the changeset to advertise {@link ChangesetCapabilities.review}. + * Clients toggle it by dispatching + * {@link ChangesetFilesReviewChangedAction | `changeset/filesReviewChanged`}; + * the server MAY also originate it (e.g. an agent self-reviewing its own + * output). + * + * There is no content version in the protocol, so review is **not** reset + * automatically when a file's contents change under a stable id. The server, + * which is the authority on what changed, resets review explicitly — either + * by re-emitting the file (via {@link ChangesetFileSetAction} or + * {@link ChangesetContentChangedAction}) without `reviewed: true`, or by + * dispatching `changeset/filesReviewChanged` with `reviewed: false`. */ reviewed?: boolean; /** diff --git a/types/common/actions.ts b/types/common/actions.ts index c2a8c7b8..2a476a10 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -75,7 +75,7 @@ import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, - ChangesetFilesReviewedChangedAction, + ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, @@ -171,7 +171,7 @@ export const enum ActionType { ChangesetStatusChanged = 'changeset/statusChanged', ChangesetFileSet = 'changeset/fileSet', ChangesetFileRemoved = 'changeset/fileRemoved', - ChangesetFilesReviewedChanged = 'changeset/filesReviewedChanged', + ChangesetFilesReviewChanged = 'changeset/filesReviewChanged', ChangesetContentChanged = 'changeset/contentChanged', ChangesetOperationsChanged = 'changeset/operationsChanged', ChangesetOperationStatusChanged = 'changeset/operationStatusChanged', @@ -288,7 +288,7 @@ export type StateAction = | ChangesetStatusChangedAction | ChangesetFileSetAction | ChangesetFileRemovedAction - | ChangesetFilesReviewedChangedAction + | ChangesetFilesReviewChangedAction | ChangesetContentChangedAction | ChangesetOperationsChangedAction | ChangesetOperationStatusChangedAction diff --git a/types/test-cases/reducers/160-changeset-filesreviewedchanged-sets-reviewed.json b/types/test-cases/reducers/160-changeset-filesreviewedchanged-sets-reviewed.json deleted file mode 100644 index ae6cc176..00000000 --- a/types/test-cases/reducers/160-changeset-filesreviewedchanged-sets-reviewed.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "description": "changeset/filesReviewedChanged sets reviewed on matching files, skips files already at the target value, and ignores files not listed", - "reducer": "changeset", - "initial": { - "status": "ready", - "files": [ - { - "id": "file:///src/a.ts", - "edit": { - "after": { - "uri": "file:///src/a.ts", - "content": { - "uri": "content:/a" - } - }, - "diff": { - "added": 1 - } - } - }, - { - "id": "file:///src/b.ts", - "reviewed": true, - "edit": { - "after": { - "uri": "file:///src/b.ts", - "content": { - "uri": "content:/b" - } - }, - "diff": { - "added": 5 - } - } - }, - { - "id": "file:///src/c.ts", - "reviewed": false, - "edit": { - "after": { - "uri": "file:///src/c.ts", - "content": { - "uri": "content:/c" - } - }, - "diff": { - "added": 2 - } - } - } - ] - }, - "actions": [ - { - "type": "changeset/filesReviewedChanged", - "fileIds": ["file:///src/a.ts", "file:///src/b.ts", "file:///src/missing.ts"], - "reviewed": true - } - ], - "expected": { - "status": "ready", - "files": [ - { - "id": "file:///src/a.ts", - "reviewed": true, - "edit": { - "after": { - "uri": "file:///src/a.ts", - "content": { - "uri": "content:/a" - } - }, - "diff": { - "added": 1 - } - } - }, - { - "id": "file:///src/b.ts", - "reviewed": true, - "edit": { - "after": { - "uri": "file:///src/b.ts", - "content": { - "uri": "content:/b" - } - }, - "diff": { - "added": 5 - } - } - }, - { - "id": "file:///src/c.ts", - "reviewed": false, - "edit": { - "after": { - "uri": "file:///src/c.ts", - "content": { - "uri": "content:/c" - } - }, - "diff": { - "added": 2 - } - } - } - ] - } -} diff --git a/types/test-cases/reducers/161-changeset-filesreviewedchanged-no-op.json b/types/test-cases/reducers/161-changeset-filesreviewedchanged-no-op.json deleted file mode 100644 index c1da6860..00000000 --- a/types/test-cases/reducers/161-changeset-filesreviewedchanged-no-op.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "description": "changeset/filesReviewedChanged is a no-op when no listed file needs its reviewed state changed", - "reducer": "changeset", - "initial": { - "status": "ready", - "files": [ - { - "id": "file:///src/a.ts", - "reviewed": true, - "edit": { - "after": { - "uri": "file:///src/a.ts", - "content": { - "uri": "content:/a" - } - }, - "diff": { - "added": 1 - } - } - } - ] - }, - "actions": [ - { - "type": "changeset/filesReviewedChanged", - "fileIds": ["file:///src/a.ts", "file:///src/missing.ts"], - "reviewed": true - } - ], - "expected": { - "status": "ready", - "files": [ - { - "id": "file:///src/a.ts", - "reviewed": true, - "edit": { - "after": { - "uri": "file:///src/a.ts", - "content": { - "uri": "content:/a" - } - }, - "diff": { - "added": 1 - } - } - } - ] - } -} diff --git a/types/test-cases/reducers/225-changeset-filesreviewchanged-marks-reviewed.json b/types/test-cases/reducers/225-changeset-filesreviewchanged-marks-reviewed.json new file mode 100644 index 00000000..2c260d58 --- /dev/null +++ b/types/test-cases/reducers/225-changeset-filesreviewchanged-marks-reviewed.json @@ -0,0 +1,39 @@ +{ + "description": "changeset/filesReviewChanged sets reviewed=true on all listed files, ignoring unknown ids", + "reducer": "changeset", + "initial": { + "status": "ready", + "files": [ + { + "id": "file:///src/a.ts", + "edit": { "after": { "uri": "file:///src/a.ts", "content": { "uri": "content:/a" } } } + }, + { + "id": "file:///src/b.ts", + "edit": { "after": { "uri": "file:///src/b.ts", "content": { "uri": "content:/b" } } } + } + ] + }, + "actions": [ + { + "type": "changeset/filesReviewChanged", + "files": ["file:///src/a.ts", "file:///src/b.ts", "file:///src/missing.ts"], + "reviewed": true + } + ], + "expected": { + "status": "ready", + "files": [ + { + "id": "file:///src/a.ts", + "edit": { "after": { "uri": "file:///src/a.ts", "content": { "uri": "content:/a" } } }, + "reviewed": true + }, + { + "id": "file:///src/b.ts", + "edit": { "after": { "uri": "file:///src/b.ts", "content": { "uri": "content:/b" } } }, + "reviewed": true + } + ] + } +} diff --git a/types/test-cases/reducers/226-changeset-filesreviewchanged-clears-reviewed.json b/types/test-cases/reducers/226-changeset-filesreviewchanged-clears-reviewed.json new file mode 100644 index 00000000..b6f25fc0 --- /dev/null +++ b/types/test-cases/reducers/226-changeset-filesreviewchanged-clears-reviewed.json @@ -0,0 +1,41 @@ +{ + "description": "changeset/filesReviewChanged sets reviewed=false, clearing review on a subset of files", + "reducer": "changeset", + "initial": { + "status": "ready", + "files": [ + { + "id": "file:///src/a.ts", + "edit": { "after": { "uri": "file:///src/a.ts", "content": { "uri": "content:/a" } } }, + "reviewed": true + }, + { + "id": "file:///src/b.ts", + "edit": { "after": { "uri": "file:///src/b.ts", "content": { "uri": "content:/b" } } }, + "reviewed": true + } + ] + }, + "actions": [ + { + "type": "changeset/filesReviewChanged", + "files": ["file:///src/a.ts"], + "reviewed": false + } + ], + "expected": { + "status": "ready", + "files": [ + { + "id": "file:///src/a.ts", + "edit": { "after": { "uri": "file:///src/a.ts", "content": { "uri": "content:/a" } } }, + "reviewed": false + }, + { + "id": "file:///src/b.ts", + "edit": { "after": { "uri": "file:///src/b.ts", "content": { "uri": "content:/b" } } }, + "reviewed": true + } + ] + } +} diff --git a/types/test-cases/reducers/227-changeset-filesreviewchanged-no-op-unknown-files.json b/types/test-cases/reducers/227-changeset-filesreviewchanged-no-op-unknown-files.json new file mode 100644 index 00000000..17095cae --- /dev/null +++ b/types/test-cases/reducers/227-changeset-filesreviewchanged-no-op-unknown-files.json @@ -0,0 +1,29 @@ +{ + "description": "changeset/filesReviewChanged is a no-op when none of the listed ids match a current file", + "reducer": "changeset", + "initial": { + "status": "ready", + "files": [ + { + "id": "file:///src/a.ts", + "edit": { "after": { "uri": "file:///src/a.ts", "content": { "uri": "content:/a" } } } + } + ] + }, + "actions": [ + { + "type": "changeset/filesReviewChanged", + "files": ["file:///src/missing.ts", "file:///src/other.ts"], + "reviewed": true + } + ], + "expected": { + "status": "ready", + "files": [ + { + "id": "file:///src/a.ts", + "edit": { "after": { "uri": "file:///src/a.ts", "content": { "uri": "content:/a" } } } + } + ] + } +} diff --git a/types/version/registry.ts b/types/version/registry.ts index 349db689..94c7bbfb 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -132,7 +132,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChangesetStatusChanged]: '0.2.0', [ActionType.ChangesetFileSet]: '0.2.0', [ActionType.ChangesetFileRemoved]: '0.2.0', - [ActionType.ChangesetFilesReviewedChanged]: '0.5.2', + [ActionType.ChangesetFilesReviewChanged]: '0.6.0', [ActionType.ChangesetContentChanged]: '0.4.0', [ActionType.ChangesetOperationsChanged]: '0.2.0', [ActionType.ChangesetOperationStatusChanged]: '0.3.0',