diff --git a/CHANGELOG.md b/CHANGELOG.md index 8266792c..26f75c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,21 @@ Spec version: `0.3.0` - Added optional `changes` field of type `ChangesSummary` to `SessionSummary`, carrying optional `additions`, `deletions`, and `files` counts so servers can advertise an at-a-glance view of a session's file-change footprint. +- Added a new annotations channel exposed on `ahp-session://annotations`. + Annotations anchor to a `(turnId, resource)` pair with an optional `range` + (omitted to anchor to the entire file), carry a `resolved` flag (newly + created annotations start unresolved), and always carry at least one entry. + Clients drive every mutation by dispatching the client-dispatchable + `annotations/set`, `annotations/removed`, `annotations/entrySet`, and + `annotations/entryRemoved` state actions directly — assigning the + `Annotation.id` / `AnnotationEntry.id` themselves — rather than through RPC + commands, so annotations inherit write-ahead replay and conflict resolution. + `SessionSummary.annotations` advertises the per-session `AnnotationsSummary` + (`{ resource, annotationCount, entryCount }`) for badge UI. +- Added an `annotations` `MessageAttachment` variant + (`MessageAnnotationsAttachment`) that references annotations on a + session's annotations channel by its `resource` URI, optionally narrowed to + an `annotationIds` array (omitted to reference every annotation). - Removed the `additions`, `deletions`, and `files` fields from `ChangesetSummary`. Aggregate counts now live on `SessionSummary.changes`; per-changeset views derive their own totals from `ChangesetState.files`. diff --git a/clients/go/CHANGELOG.md b/clients/go/CHANGELOG.md index ed47b640..9f4d4f39 100644 --- a/clients/go/CHANGELOG.md +++ b/clients/go/CHANGELOG.md @@ -50,6 +50,18 @@ Implements AHP 0.3.0. `idle → running → error` lifecycle of a changeset operation. - `AgentCustomization._meta` provider metadata field. - Optional `changes` field on `SessionSummary` (`ChangesSummary` with optional `additions`, `deletions`, and `files` counts) summarising a session's file-change footprint. +- New annotations channel wire types (`ahp-session://annotations`): + `AnnotationsState`, `Annotation`, `AnnotationEntry`, + `AnnotationsSummary`; the client-dispatchable `AnnotationsSetAction`, + `AnnotationsRemovedAction`, `AnnotationsEntrySetAction`, + `AnnotationsEntryRemovedAction` variants — clients drive every annotation + mutation by dispatching these directly, assigning the `Annotation.Id` / + `AnnotationEntry.Id` themselves; + `ApplyActionToAnnotations` (stub mirroring `ApplyActionToChangeset`); and + `SnapshotState.Annotations`. +- `MessageAnnotationsAttachment` (`annotations` `MessageAttachment` variant) + referencing annotations on a session's annotations channel by `Resource` + URI, optionally narrowed to an `AnnotationIds` array. ### Changed diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index c9d068fb..79a6e8bf 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -1166,3 +1166,21 @@ func ApplyActionToChangeset(state *ahptypes.ChangesetState, action ahptypes.Stat } return ReduceOutcomeOutOfScope } + +// ─── Annotations Reducer ───────────────────────────────────────── + +// ApplyActionToAnnotations is the entry point for annotations actions. +// Mirrors the Rust client's stub: every recognized annotations action +// short-circuits as [ReduceOutcomeNoOp] until the full annotations +// reducer is ported. Unrelated actions return [ReduceOutcomeOutOfScope]. +func ApplyActionToAnnotations(state *ahptypes.AnnotationsState, action ahptypes.StateAction) ReduceOutcome { + _ = state + switch action.Value.(type) { + case *ahptypes.AnnotationsSetAction, + *ahptypes.AnnotationsRemovedAction, + *ahptypes.AnnotationsEntrySetAction, + *ahptypes.AnnotationsEntryRemovedAction: + return ReduceOutcomeNoOp + } + return ReduceOutcomeOutOfScope +} diff --git a/clients/go/ahp/reducers_fixture_test.go b/clients/go/ahp/reducers_fixture_test.go index 4fe14b7b..0d64fddd 100644 --- a/clients/go/ahp/reducers_fixture_test.go +++ b/clients/go/ahp/reducers_fixture_test.go @@ -154,6 +154,9 @@ func TestFixtureDrivenReducerParity(t *testing.T) { case "changeset": // Changeset reducer logic is deferred — skip. tt.Skip("changeset reducer is a stub in this client (parity with Rust)") + case "annotations": + // Annotations reducer logic is deferred — skip. + tt.Skip("annotations reducer is a stub in this client (parity with Rust)") case "resourceWatch": // Resource-watch reducer logic is deferred — skip. tt.Skip("resourceWatch reducer is a stub in this client (parity with Rust)") diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 5362338b..50949583 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -68,6 +68,10 @@ const ( ActionTypeChangesetOperationsChanged ActionType = "changeset/operationsChanged" ActionTypeChangesetOperationStatusChanged ActionType = "changeset/operationStatusChanged" ActionTypeChangesetCleared ActionType = "changeset/cleared" + ActionTypeAnnotationsSet ActionType = "annotations/set" + ActionTypeAnnotationsRemoved ActionType = "annotations/removed" + ActionTypeAnnotationsEntrySet ActionType = "annotations/entrySet" + ActionTypeAnnotationsEntryRemoved ActionType = "annotations/entryRemoved" ActionTypeRootTerminalsChanged ActionType = "root/terminalsChanged" ActionTypeRootConfigChanged ActionType = "root/configChanged" ActionTypeTerminalData ActionType = "terminal/data" @@ -796,6 +800,63 @@ type ChangesetClearedAction struct { Type ActionType `json:"type"` } +// Upsert an {@link Annotation} in the annotations channel — adds a new +// annotation, or replaces an existing one identified by +// {@link Annotation.id}. +// +// Dispatched by a client to create an annotation (together with its +// mandatory first entry) or to re-anchor / resolve an existing one; the +// dispatching client assigns the {@link Annotation.id} and the id of any +// new entry. When replacing, the full annotation payload (including its +// {@link Annotation.entries | entries} list) is substituted; producers +// SHOULD prefer {@link AnnotationsEntrySetAction} for per-entry edits to +// keep wire updates small. +type AnnotationsSetAction struct { + Type ActionType `json:"type"` + // The new or replacement annotation. MUST contain at least one entry. + Annotation Annotation `json:"annotation"` +} + +// Remove an {@link Annotation} from the channel by its id. +// +// Dispatched to delete an entire annotation and every entry it contains. +// Because the protocol forbids empty annotations, a client that wants to +// remove the last remaining entry dispatches this action — collapsing the +// annotation — rather than {@link AnnotationsEntryRemovedAction}. +type AnnotationsRemovedAction struct { + Type ActionType `json:"type"` + // The {@link Annotation.id} of the annotation to remove. + AnnotationId string `json:"annotationId"` +} + +// Upsert an {@link AnnotationEntry} within an existing annotation — adds a +// new entry, or replaces one identified by {@link AnnotationEntry.id}. The +// dispatching client assigns the {@link AnnotationEntry.id} of a new entry. +// If {@link annotationId} does not match any current annotation the action +// is a no-op. +type AnnotationsEntrySetAction struct { + Type ActionType `json:"type"` + // The {@link Annotation.id} the entry belongs to. + AnnotationId string `json:"annotationId"` + // The new or replacement entry. + Entry AnnotationEntry `json:"entry"` +} + +// Remove a single {@link AnnotationEntry} from an annotation without +// collapsing the annotation itself. Used when more than one entry remains — +// to remove the last entry a client dispatches {@link AnnotationsRemovedAction} +// instead, since the protocol forbids empty annotations. +// +// If either {@link annotationId} or {@link entryId} does not match the +// current state the action is a no-op. +type AnnotationsEntryRemovedAction struct { + Type ActionType `json:"type"` + // The {@link Annotation.id} the entry belongs to. + AnnotationId string `json:"annotationId"` + // The {@link AnnotationEntry.id} to remove. + EntryId string `json:"entryId"` +} + // Fired when the list of known terminals changes. // // Full-replacement semantics: the `terminals` array replaces the previous @@ -1001,6 +1062,10 @@ func (*ChangesetFileRemovedAction) isStateAction() {} func (*ChangesetOperationsChangedAction) isStateAction() {} func (*ChangesetOperationStatusChangedAction) isStateAction() {} func (*ChangesetClearedAction) isStateAction() {} +func (*AnnotationsSetAction) isStateAction() {} +func (*AnnotationsRemovedAction) isStateAction() {} +func (*AnnotationsEntrySetAction) isStateAction() {} +func (*AnnotationsEntryRemovedAction) isStateAction() {} func (*RootTerminalsChangedAction) isStateAction() {} func (*TerminalDataAction) isStateAction() {} func (*TerminalInputAction) isStateAction() {} @@ -1329,6 +1394,30 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "annotations/set": + var value AnnotationsSetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "annotations/removed": + var value AnnotationsRemovedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "annotations/entrySet": + var value AnnotationsEntrySetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "annotations/entryRemoved": + var value AnnotationsEntryRemovedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "root/terminalsChanged": var value RootTerminalsChangedAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/notifications.generated.go b/clients/go/ahptypes/notifications.generated.go index 3d37cdc7..f74c8b2f 100644 --- a/clients/go/ahptypes/notifications.generated.go +++ b/clients/go/ahptypes/notifications.generated.go @@ -189,4 +189,9 @@ type PartialSessionSummary struct { // session's footprint (e.g., for list rendering) without requiring the // client to subscribe to a changeset. Changes *ChangesSummary `json:"changes,omitempty"` + // Lightweight summary of this session's inline annotations channel + // (`ahp-session://annotations`). Surfaced so badge UI can render + // annotation / entry counts without subscribing. Absent when the session + // does not expose an annotations channel. + Annotations *AnnotationsSummary `json:"annotations,omitempty"` } diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 0b8a3e51..bad8d352 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -131,6 +131,8 @@ const ( MessageAttachmentKindEmbeddedResource MessageAttachmentKind = "embeddedResource" // An attachment that references a resource by URI. MessageAttachmentKindResource MessageAttachmentKind = "resource" + // An attachment that references annotations on an annotations channel. + MessageAttachmentKindAnnotations MessageAttachmentKind = "annotations" ) // Discriminant for response part types. @@ -692,6 +694,11 @@ type SessionSummary struct { // session's footprint (e.g., for list rendering) without requiring the // client to subscribe to a changeset. Changes *ChangesSummary `json:"changes,omitempty"` + // Lightweight summary of this session's inline annotations channel + // (`ahp-session://annotations`). Surfaced so badge UI can render + // annotation / entry counts without subscribing. Absent when the session + // does not expose an annotations channel. + Annotations *AnnotationsSummary `json:"annotations,omitempty"` } // Aggregate counts describing the file changes associated with a session. @@ -1140,6 +1147,47 @@ type MessageResourceAttachment struct { Selection *TextSelection `json:"selection,omitempty"` } +// An attachment that references annotations on a session's annotations +// channel (see {@link AnnotationsState}). +// +// When {@link annotationIds} is omitted the attachment references every +// annotation on the channel; when present it references only the listed +// {@link Annotation.id | annotation ids}. +type MessageAnnotationsAttachment struct { + // A human-readable label for the attachment (e.g. the filename of a file + // attachment). Used for display in UI. + Label string `json:"label"` + // If defined, the range in {@link Message.text} that references this + // attachment. This is a text range, not a byte range. + Range *TextRange `json:"range,omitempty"` + // Advisory display hint for clients rendering this attachment. Recognized + // values include: + // + // - `'image'`: the attachment is an image + // - `'document'`: the attachment is a textual document + // - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + // - `'directory'`: the attachment is a folder + // - `'selection'`: the attachment is a selection within a document + // + // Implementations MAY provide additional values; clients SHOULD fall back + // to a reasonable default when an unknown value is encountered. + DisplayKind *string `json:"displayKind,omitempty"` + // Additional implementation-defined metadata for the attachment. + // + // If the attachment was produced by the `completions` command, the client + // MUST preserve every property of `_meta` originally returned by the agent + // host when sending the user message containing the accepted completion. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Discriminant + Type MessageAttachmentKind `json:"type"` + // The annotations channel URI (typically `ahp-session://annotations`). + // Matches {@link AnnotationsSummary.resource}. + Resource URI `json:"resource"` + // Specific {@link Annotation.id | annotation ids} to reference. When + // omitted, the attachment references all annotations on the channel. + AnnotationIds []string `json:"annotationIds,omitempty"` +} + type MarkdownResponsePart struct { // Discriminant Kind ResponsePartKind `json:"kind"` @@ -2351,6 +2399,82 @@ type ChangesetOperation struct { Error *ErrorInfo `json:"error,omitempty"` } +// Lightweight per-session summary of the annotations channel, surfaced on +// {@link SessionSummary.annotations} so badge UI can render annotation / +// entry counts without subscribing to the channel itself. +type AnnotationsSummary struct { + // The subscribable annotations channel URI for the owning session + // (typically `ahp-session://annotations`). Surfaced explicitly even + // though it is derivable from the session URI so badge UI does not need + // to know the derivation rule. + Resource URI `json:"resource"` + // Total number of {@link Annotation} entries in the channel. + AnnotationCount int64 `json:"annotationCount"` + // Total number of {@link AnnotationEntry} entries across every annotation. + EntryCount int64 `json:"entryCount"` +} + +// Full state for a session's annotations channel, returned when a client +// subscribes to an `ahp-session://annotations` URI. +type AnnotationsState struct { + // Annotations in this channel, keyed by {@link Annotation.id}. + Annotations []Annotation `json:"annotations"` +} + +// A conversation anchored to a specific file produced by a specific turn, +// optionally narrowed to a range within that file. +// +// {@link turnId} anchors the annotation to the file versions that turn +// produced, so a later turn that rewrites the same file does not silently +// invalidate the annotation's anchor — clients can resolve {@link resource} +// and {@link range} against the turn's changeset. When {@link range} is +// omitted the annotation is anchored to the entire file. +// +// Every annotation MUST contain at least one {@link AnnotationEntry}. An +// {@link AnnotationsSetAction} that creates an annotation therefore carries +// its mandatory first entry, and removing the last remaining entry collapses +// the annotation via {@link AnnotationsRemovedAction} rather than leaving an +// empty annotation behind. +type Annotation struct { + // Stable identifier within the annotations channel. Assigned by the client + // that dispatches the creating {@link AnnotationsSetAction}. + Id string `json:"id"` + // Turn that produced the file versions this annotation is anchored to. + // Matches a {@link Turn.id} on the owning session. + TurnId string `json:"turnId"` + // The file the annotation is anchored to. + Resource URI `json:"resource"` + // Range within {@link resource} the annotation is anchored to. When + // omitted the annotation is anchored to the entire file. + Range *TextRange `json:"range,omitempty"` + // Whether the annotation has been resolved. Newly created annotations are + // always unresolved (`false`); a client marks an annotation resolved (or + // re-opens it) by dispatching an {@link AnnotationsSetAction} carrying the + // updated flag. + Resolved bool `json:"resolved"` + // Entries in this annotation, in dispatch order (oldest first). MUST + // contain at least one entry. + Entries []AnnotationEntry `json:"entries"` + // Producer-defined opaque metadata, surfaced to tooling but not + // interpreted by the protocol. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// A single entry within an {@link Annotation}. +type AnnotationEntry struct { + // Stable identifier within the enclosing annotation. Assigned by the client + // that dispatches the {@link AnnotationsEntrySetAction} (or the enclosing + // {@link AnnotationsSetAction}) introducing the entry. + Id string `json:"id"` + // Entry body. A bare `string` is rendered as plain text; pass + // `{ markdown: "…" }` to opt into Markdown rendering. See + // {@link StringOrMarkdown}. + Text StringOrMarkdown `json:"text"` + // Producer-defined opaque metadata, surfaced to tooling but not + // interpreted by the protocol. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + // OTLP telemetry channels the agent host emits. // // Each field, when present, is either a literal channel URI or an @@ -3056,6 +3180,7 @@ type isMessageAttachment interface{ isMessageAttachment() } func (*SimpleMessageAttachment) isMessageAttachment() {} func (*MessageEmbeddedResourceAttachment) isMessageAttachment() {} func (*MessageResourceAttachment) isMessageAttachment() {} +func (*MessageAnnotationsAttachment) isMessageAttachment() {} // MessageAttachmentUnknown carries an unrecognized MessageAttachment variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. type MessageAttachmentUnknown struct { @@ -3089,6 +3214,12 @@ func (u *MessageAttachment) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "annotations": + var value MessageAnnotationsAttachment + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value default: raw := make(json.RawMessage, len(data)) copy(raw, data) @@ -3482,14 +3613,15 @@ func (u ToolCallContributor) MarshalJSON() ([]byte, error) { } // SnapshotState is the state payload of a snapshot — root, session, -// terminal, or changeset state. The active variant is chosen by which +// terminal, changeset, or annotations state. The active variant is chosen by which // pointer field is non-nil; UnmarshalJSON probes for required fields in -// the canonical order (session → terminal → changeset → root). +// the canonical order (session → terminal → changeset → annotations → root). type SnapshotState struct { - Root *RootState `json:"-"` - Session *SessionState `json:"-"` - Terminal *TerminalState `json:"-"` - Changeset *ChangesetState `json:"-"` + Root *RootState `json:"-"` + Session *SessionState `json:"-"` + Terminal *TerminalState `json:"-"` + Changeset *ChangesetState `json:"-"` + Annotations *AnnotationsState `json:"-"` } // MarshalJSON encodes whichever variant is currently populated. @@ -3501,6 +3633,8 @@ func (s SnapshotState) MarshalJSON() ([]byte, error) { return json.Marshal(s.Terminal) case s.Changeset != nil: return json.Marshal(s.Changeset) + case s.Annotations != nil: + return json.Marshal(s.Annotations) case s.Root != nil: return json.Marshal(s.Root) default: @@ -3535,6 +3669,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { return err } s.Changeset = &v + case containsAll(probe, "annotations"): + var v AnnotationsState + if err := json.Unmarshal(data, &v); err != nil { + return err + } + s.Annotations = &v default: var v RootState if err := json.Unmarshal(data, &v); err != nil { diff --git a/clients/kotlin/CHANGELOG.md b/clients/kotlin/CHANGELOG.md index 8d40a068..3b9837e8 100644 --- a/clients/kotlin/CHANGELOG.md +++ b/clients/kotlin/CHANGELOG.md @@ -49,6 +49,18 @@ Implements AHP 0.3.0. `idle → running → error` lifecycle of a changeset operation. - `AgentCustomization._meta` provider metadata field. - Optional `changes` field on `SessionSummary` (`ChangesSummary` with optional `additions`, `deletions`, and `files` counts) summarising a session's file-change footprint. +- New annotations channel (`ahp-session://annotations`): `AnnotationsState`, + `Annotation`, `AnnotationEntry`, + `AnnotationsSummary`; the `annotationsReducer` top-level function and + `AnnotationsReducer` object; and the client-dispatchable `annotations/set`, + `annotations/removed`, `annotations/entrySet`, and `annotations/entryRemoved` + action variants — clients drive every annotation mutation by dispatching + these directly (assigning the `Annotation.id` / `AnnotationEntry.id` + themselves); and `SnapshotState.Annotations`. + `SessionSummary.annotations` surfaces the per-session `AnnotationsSummary`. +- `MessageAnnotationsAttachment` (`annotations` `MessageAttachment` variant) + referencing annotations on a session's annotations channel by `resource` + URI, optionally narrowed to an `annotationIds` array. ### Changed 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 811fe495..c170c009 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -21,6 +21,9 @@ import com.microsoft.agenthostprotocol.generated.ChildCustomizationPrompt import com.microsoft.agenthostprotocol.generated.ChildCustomizationRule import com.microsoft.agenthostprotocol.generated.ChildCustomizationSkill import com.microsoft.agenthostprotocol.generated.ChildCustomizationUnknown +import com.microsoft.agenthostprotocol.generated.AnnotationEntry +import com.microsoft.agenthostprotocol.generated.Annotation +import com.microsoft.agenthostprotocol.generated.AnnotationsState import com.microsoft.agenthostprotocol.generated.ConfirmationOption import com.microsoft.agenthostprotocol.generated.Customization import com.microsoft.agenthostprotocol.generated.CustomizationDirectory @@ -52,6 +55,10 @@ import com.microsoft.agenthostprotocol.generated.StateActionChangesetFileSet import com.microsoft.agenthostprotocol.generated.StateActionChangesetOperationsChanged import com.microsoft.agenthostprotocol.generated.StateActionChangesetOperationStatusChanged import com.microsoft.agenthostprotocol.generated.StateActionChangesetStatusChanged +import com.microsoft.agenthostprotocol.generated.StateActionAnnotationsEntryRemoved +import com.microsoft.agenthostprotocol.generated.StateActionAnnotationsEntrySet +import com.microsoft.agenthostprotocol.generated.StateActionAnnotationsRemoved +import com.microsoft.agenthostprotocol.generated.StateActionAnnotationsSet import com.microsoft.agenthostprotocol.generated.StateActionRootActiveSessionsChanged import com.microsoft.agenthostprotocol.generated.StateActionRootAgentsChanged import com.microsoft.agenthostprotocol.generated.StateActionRootConfigChanged @@ -145,9 +152,9 @@ import kotlinx.serialization.json.JsonElement * no mutation of [state] and no side effects. * * The companion top-level functions ([rootReducer], [sessionReducer], - * [terminalReducer], [changesetReducer], [resourceWatchReducer]) are the canonical implementations. + * [terminalReducer], [changesetReducer], [annotationsReducer], [resourceWatchReducer]) are the canonical implementations. * The object instances on this interface ([RootReducer], [SessionReducer], - * [TerminalReducer], [ChangesetReducer]) wrap them for use as values where + * [TerminalReducer], [ChangesetReducer], [AnnotationsReducer]) wrap them for use as values where * an instance is needed. */ public fun interface Reducer { @@ -178,6 +185,12 @@ public object ChangesetReducer : Reducer { changesetReducer(state, action) } +/** Pure annotations reducer as a [Reducer] instance. Delegates to [annotationsReducer]. */ +public object AnnotationsReducer : Reducer { + override fun reduce(state: AnnotationsState, action: StateAction): AnnotationsState = + annotationsReducer(state, action) +} + /** Pure resource-watch reducer as a [Reducer] instance. Delegates to [resourceWatchReducer]. */ public object ResourceWatchReducer : Reducer { override fun reduce(state: ResourceWatchState, action: StateAction): ResourceWatchState = @@ -1337,6 +1350,81 @@ public fun changesetReducer(state: ChangesetState, action: StateAction): Changes else -> state } +// ─── Annotations Reducer ────────────────────────────────────────── + +/** + * Pure reducer for [AnnotationsState]. Handles all annotations-channel action + * variants; actions belonging to other channels (or unknown variants) are + * no-ops that return [state] unchanged. + * + * The single-entry-minimum invariant is enforced by the server, not the + * reducer — a malformed server that removes an annotation's last entry via + * `annotations/entryRemoved` would leave an empty annotation, which is + * observable but not catastrophic. + */ +public fun annotationsReducer(state: AnnotationsState, action: StateAction): AnnotationsState = when (action) { + is StateActionAnnotationsSet -> { + val annotation = action.value.annotation + val idx = state.annotations.indexOfFirst { it.id == annotation.id } + if (idx < 0) { + state.copy(annotations = state.annotations + annotation) + } else { + val next = state.annotations.toMutableList().also { it[idx] = annotation } + state.copy(annotations = next) + } + } + + is StateActionAnnotationsRemoved -> { + val idx = state.annotations.indexOfFirst { it.id == action.value.annotationId } + if (idx < 0) { + state + } else { + val next: List = state.annotations.toMutableList().also { it.removeAt(idx) } + state.copy(annotations = next) + } + } + + is StateActionAnnotationsEntrySet -> { + val tIdx = state.annotations.indexOfFirst { it.id == action.value.annotationId } + if (tIdx < 0) { + state + } else { + val annotation = state.annotations[tIdx] + val entry = action.value.entry + val cIdx = annotation.entries.indexOfFirst { it.id == entry.id } + val nextEntries = if (cIdx < 0) { + annotation.entries + entry + } else { + annotation.entries.toMutableList().also { it[cIdx] = entry } + } + val nextAnnotations = state.annotations.toMutableList() + .also { it[tIdx] = annotation.copy(entries = nextEntries) } + state.copy(annotations = nextAnnotations) + } + } + + is StateActionAnnotationsEntryRemoved -> { + val tIdx = state.annotations.indexOfFirst { it.id == action.value.annotationId } + if (tIdx < 0) { + state + } else { + val annotation = state.annotations[tIdx] + val cIdx = annotation.entries.indexOfFirst { it.id == action.value.entryId } + if (cIdx < 0) { + state + } else { + val nextEntries: List = annotation.entries.toMutableList() + .also { it.removeAt(cIdx) } + val nextAnnotations = state.annotations.toMutableList() + .also { it[tIdx] = annotation.copy(entries = nextEntries) } + state.copy(annotations = nextAnnotations) + } + } + } + + else -> state +} + /** * Pure reducer for an [ResourceWatchState]. Pattern-matches on the * `resourceWatch/changed` action; actions belonging to other channels 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 566f8992..ceb0677f 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 @@ -124,6 +124,14 @@ enum class ActionType { CHANGESET_OPERATION_STATUS_CHANGED, @SerialName("changeset/cleared") CHANGESET_CLEARED, + @SerialName("annotations/set") + ANNOTATIONS_SET, + @SerialName("annotations/removed") + ANNOTATIONS_REMOVED, + @SerialName("annotations/entrySet") + ANNOTATIONS_ENTRY_SET, + @SerialName("annotations/entryRemoved") + ANNOTATIONS_ENTRY_REMOVED, @SerialName("root/terminalsChanged") ROOT_TERMINALS_CHANGED, @SerialName("root/configChanged") @@ -267,7 +275,7 @@ data class SessionToolCallStartAction( val toolCallId: String, /** * Additional provider-specific metadata for this tool call. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `ptyTerminal` key with `{ input: string; output: string }` * indicates the tool operated on a terminal (both `input` and `output` may @@ -303,7 +311,7 @@ data class SessionToolCallDeltaAction( val toolCallId: String, /** * Additional provider-specific metadata for this tool call. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `ptyTerminal` key with `{ input: string; output: string }` * indicates the tool operated on a terminal (both `input` and `output` may @@ -334,7 +342,7 @@ data class SessionToolCallReadyAction( val toolCallId: String, /** * Additional provider-specific metadata for this tool call. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `ptyTerminal` key with `{ input: string; output: string }` * indicates the tool operated on a terminal (both `input` and `output` may @@ -417,7 +425,7 @@ data class SessionToolCallCompleteAction( val toolCallId: String, /** * Additional provider-specific metadata for this tool call. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `ptyTerminal` key with `{ input: string; output: string }` * indicates the tool operated on a terminal (both `input` and `output` may @@ -448,7 +456,7 @@ data class SessionToolCallResultConfirmedAction( val toolCallId: String, /** * Additional provider-specific metadata for this tool call. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `ptyTerminal` key with `{ input: string; output: string }` * indicates the tool operated on a terminal (both `input` and `output` may @@ -800,7 +808,7 @@ data class SessionToolCallContentChangedAction( val toolCallId: String, /** * Additional provider-specific metadata for this tool call. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `ptyTerminal` key with `{ input: string; output: string }` * indicates the tool operated on a terminal (both `input` and `output` may @@ -877,6 +885,50 @@ data class ChangesetClearedAction( val type: ActionType ) +@Serializable +data class AnnotationsSetAction( + val type: ActionType, + /** + * The new or replacement annotation. MUST contain at least one entry. + */ + val annotation: Annotation +) + +@Serializable +data class AnnotationsRemovedAction( + val type: ActionType, + /** + * The {@link Annotation.id} of the annotation to remove. + */ + val annotationId: String +) + +@Serializable +data class AnnotationsEntrySetAction( + val type: ActionType, + /** + * The {@link Annotation.id} the entry belongs to. + */ + val annotationId: String, + /** + * The new or replacement entry. + */ + val entry: AnnotationEntry +) + +@Serializable +data class AnnotationsEntryRemovedAction( + val type: ActionType, + /** + * The {@link Annotation.id} the entry belongs to. + */ + val annotationId: String, + /** + * The {@link AnnotationEntry.id} to remove. + */ + val entryId: String +) + @Serializable data class RootTerminalsChangedAction( val type: ActionType, @@ -1085,6 +1137,10 @@ sealed interface StateAction @JvmInline value class StateActionChangesetOperationsChanged(val value: ChangesetOperationsChangedAction) : StateAction @JvmInline value class StateActionChangesetOperationStatusChanged(val value: ChangesetOperationStatusChangedAction) : StateAction @JvmInline value class StateActionChangesetCleared(val value: ChangesetClearedAction) : StateAction +@JvmInline value class StateActionAnnotationsSet(val value: AnnotationsSetAction) : StateAction +@JvmInline value class StateActionAnnotationsRemoved(val value: AnnotationsRemovedAction) : StateAction +@JvmInline value class StateActionAnnotationsEntrySet(val value: AnnotationsEntrySetAction) : StateAction +@JvmInline value class StateActionAnnotationsEntryRemoved(val value: AnnotationsEntryRemovedAction) : StateAction @JvmInline value class StateActionRootTerminalsChanged(val value: RootTerminalsChangedAction) : StateAction @JvmInline value class StateActionRootConfigChanged(val value: RootConfigChangedAction) : StateAction @JvmInline value class StateActionTerminalData(val value: TerminalDataAction) : StateAction @@ -1163,6 +1219,10 @@ internal object StateActionSerializer : KSerializer { "changeset/operationsChanged" -> StateActionChangesetOperationsChanged(input.json.decodeFromJsonElement(ChangesetOperationsChangedAction.serializer(), element)) "changeset/operationStatusChanged" -> StateActionChangesetOperationStatusChanged(input.json.decodeFromJsonElement(ChangesetOperationStatusChangedAction.serializer(), element)) "changeset/cleared" -> StateActionChangesetCleared(input.json.decodeFromJsonElement(ChangesetClearedAction.serializer(), element)) + "annotations/set" -> StateActionAnnotationsSet(input.json.decodeFromJsonElement(AnnotationsSetAction.serializer(), element)) + "annotations/removed" -> StateActionAnnotationsRemoved(input.json.decodeFromJsonElement(AnnotationsRemovedAction.serializer(), element)) + "annotations/entrySet" -> StateActionAnnotationsEntrySet(input.json.decodeFromJsonElement(AnnotationsEntrySetAction.serializer(), element)) + "annotations/entryRemoved" -> StateActionAnnotationsEntryRemoved(input.json.decodeFromJsonElement(AnnotationsEntryRemovedAction.serializer(), element)) "root/terminalsChanged" -> StateActionRootTerminalsChanged(input.json.decodeFromJsonElement(RootTerminalsChangedAction.serializer(), element)) "root/configChanged" -> StateActionRootConfigChanged(input.json.decodeFromJsonElement(RootConfigChangedAction.serializer(), element)) "terminal/data" -> StateActionTerminalData(input.json.decodeFromJsonElement(TerminalDataAction.serializer(), element)) @@ -1234,6 +1294,10 @@ internal object StateActionSerializer : KSerializer { is StateActionChangesetOperationsChanged -> output.json.encodeToJsonElement(ChangesetOperationsChangedAction.serializer(), value.value) is StateActionChangesetOperationStatusChanged -> output.json.encodeToJsonElement(ChangesetOperationStatusChangedAction.serializer(), value.value) is StateActionChangesetCleared -> output.json.encodeToJsonElement(ChangesetClearedAction.serializer(), value.value) + is StateActionAnnotationsSet -> output.json.encodeToJsonElement(AnnotationsSetAction.serializer(), value.value) + is StateActionAnnotationsRemoved -> output.json.encodeToJsonElement(AnnotationsRemovedAction.serializer(), value.value) + is StateActionAnnotationsEntrySet -> output.json.encodeToJsonElement(AnnotationsEntrySetAction.serializer(), value.value) + is StateActionAnnotationsEntryRemoved -> output.json.encodeToJsonElement(AnnotationsEntryRemovedAction.serializer(), value.value) is StateActionRootTerminalsChanged -> output.json.encodeToJsonElement(RootTerminalsChangedAction.serializer(), value.value) is StateActionRootConfigChanged -> output.json.encodeToJsonElement(RootConfigChangedAction.serializer(), value.value) is StateActionTerminalData -> output.json.encodeToJsonElement(TerminalDataAction.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 96f9d7c5..e532cba7 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -72,9 +72,9 @@ enum class ResourceType { /** * How {@link ResourceWriteParams.data} is placed within the target file. - * + * * Each mode interprets {@link ResourceWriteParams.position} differently: - * + * * - `truncate` (default): rooted at the **start** of the file. The file is * truncated at `position` (0 by default) and `data` is written from that * offset, so the resulting file is `existing[0..position] + data`. With @@ -113,7 +113,7 @@ data class InitializeParams( * Protocol versions the client is willing to speak, ordered from most * preferred to least preferred. Each entry is a [SemVer](https://semver.org) * `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). - * + * * The server selects one entry and returns it as `InitializeResult.protocolVersion`. * If the server cannot speak any of the offered versions, it MUST return * error code `-32005` (`UnsupportedProtocolVersion`). @@ -135,7 +135,7 @@ data class InitializeParams( val locale: String? = null, /** * Optional client capability declarations. - * + * * Servers SHOULD only advertise features whose corresponding client * capability is set here. Absent means "not declared" — the server * MUST assume the client does not support the feature. @@ -187,7 +187,7 @@ data class ClientCapabilities( * [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. * it can host the View sandbox, run the `ui/​*` protocol against it, * and forward `mcp://`-channel traffic on the App's behalf. - * + * * Hosts SHOULD only populate * {@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`} * (and expose the corresponding @@ -293,7 +293,7 @@ data class CreateSessionParams( val model: ModelSelection? = null, /** * Initial custom agent selection for the new session. - * + * * Omit to start the session with no custom agent selected (provider default). */ val agent: AgentSelection? = null, @@ -313,7 +313,7 @@ data class CreateSessionParams( val config: Map? = null, /** * Eagerly claim the active client role for the new session. - * + * * When provided, the server initializes the session with this client as the * active client, equivalent to dispatching a `session/activeClientChanged` * action immediately after creation. The `clientId` MUST match the @@ -970,9 +970,9 @@ data class CompletionItem( * by `insertText`. The range is the half-open interval * `[rangeStart, rangeEnd)` of character offsets, measured in UTF-16 code * units. - * + * * When omitted, the client SHOULD insert `insertText` at the cursor. - * + * * Note: this range refers to positions in the *current* input. The * attachment's own `rangeStart`/`rangeEnd` (when present) refer to * positions in the final {@link Message.text} after the item is diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt index c6c0aabc..9aeea82f 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt @@ -83,7 +83,7 @@ data class PermissionDeniedErrorData( data class UnsupportedProtocolVersionErrorData( /** * Protocol versions the server is willing to speak. - * + * * Each entry is either a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` * string (e.g. `"0.1.0"`) or a [SemVer range](https://semver.org/#spec-item-11) * constraint (e.g. `">=0.1.0 <0.3.0"` or `"^0.2.0"`). diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt index 425ef812..e7128cda 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt @@ -76,7 +76,7 @@ data class SessionSummaryChangedParams( val session: String, /** * Mutable summary fields that changed; omitted fields are unchanged. - * + * * Identity fields (`resource`, `provider`, `createdAt`) never change and * MUST be omitted by senders; receivers SHOULD ignore them if present. */ @@ -183,7 +183,7 @@ data class PartialSessionSummary( val model: ModelSelection? = null, /** * Currently selected custom agent. - * + * * Absent (`undefined`) means no custom agent is selected for this session * — the session uses the provider's default behavior. */ @@ -198,5 +198,12 @@ data class PartialSessionSummary( * session's footprint (e.g., for list rendering) without requiring the * client to subscribe to a changeset. */ - val changes: ChangesSummary? = null + val changes: ChangesSummary? = null, + /** + * Lightweight summary of this session's inline annotations channel + * (`ahp-session://annotations`). Surfaced so badge UI can render + * annotation / entry counts without subscribing. Absent when the session + * does not expose an annotations channel. + */ + val annotations: AnnotationsSummary? = null ) 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 3aa44a42..9af47c60 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 @@ -116,7 +116,7 @@ enum class SessionLifecycle { /** * Bitset of summary-level session status flags. - * + * * Use bitwise checks instead of equality for non-terminal activity. For example, * `status & SessionStatus.InProgress` matches both ordinary in-progress turns * and turns that are paused waiting for input. @@ -273,7 +273,12 @@ enum class MessageAttachmentKind { * An attachment that references a resource by URI. */ @SerialName("resource") - RESOURCE + RESOURCE, + /** + * An attachment that references annotations on an annotations channel. + */ + @SerialName("annotations") + ANNOTATIONS } /** @@ -314,7 +319,7 @@ enum class ToolCallStatus { /** * How a tool call was confirmed for execution. - * + * * - `NotNeeded` — No confirmation required (auto-approved) * - `UserAction` — User explicitly approved * - `Setting` — Approved by a persistent user setting @@ -382,7 +387,7 @@ enum class ToolResultContentType { /** * Discriminant for the kind of customization. - * + * * Top-level entries in {@link SessionState.customizations} and * {@link AgentInfo.customizations} are either container customizations * ({@link CustomizationType.Plugin | `Plugin`} or @@ -494,7 +499,7 @@ enum class McpAuthRequiredReason { * Step-up auth: a token is present but its scopes are insufficient for * the requested operation (HTTP 403 with * `WWW-Authenticate: Bearer error="insufficient_scope"`). - * + * * Unlike {@link Required} and {@link Expired} — which typically surface * before any tool work is in flight — `InsufficientScope` is almost * always triggered by an MCP request issued mid-turn (a `tools/call`, @@ -537,7 +542,7 @@ enum class ChangesetStatus { /** * Execution lifecycle of a {@link ChangesetOperation}. - * + * * An operation is invoked imperatively via `invokeChangesetOperation`, but * its progress and outcome are reflected back into changeset state so that * every subscriber observes a consistent view (e.g. a spinner on a "Create @@ -606,10 +611,10 @@ data class Icon( /** * A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a * `data:` URI with Base64-encoded image data. - * + * * Consumers SHOULD take steps to ensure URLs serving icons are from the * same domain as the client/server or a trusted domain. - * + * * Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain * executable JavaScript. */ @@ -622,7 +627,7 @@ data class Icon( /** * Optional array of strings that specify sizes at which the icon can be used. * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. - * + * * If not provided, the client should assume that the icon can be used at any size. */ val sizes: List? = null, @@ -630,7 +635,7 @@ data class Icon( * Optional specifier for the theme this icon is designed for. `"light"` indicates * the icon is designed to be used with a light background, and `"dark"` indicates * the icon is designed to be used with a dark background. - * + * * If not provided, the client should assume the icon can be used with any theme. */ val theme: String? = null @@ -700,13 +705,13 @@ data class ProtectedResourceMetadata( val resourceTosUri: String? = null, /** * AHP extension. Whether authentication is required for this resource. - * + * * - `true` (default) — the agent cannot be used without a valid token. * The server SHOULD return `AuthRequired` (`-32007`) if the client * attempts to use the agent without authenticating. * - `false` — the agent works without authentication but MAY offer * enhanced capabilities when a token is provided. - * + * * Clients SHOULD treat an absent field the same as `true`. */ val required: Boolean? = null @@ -732,7 +737,7 @@ data class RootState( val config: RootConfigState? = null, /** * Additional implementation-defined metadata about the agent host itself. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. */ @SerialName("_meta") @@ -771,7 +776,7 @@ data class AgentInfo( val models: List, /** * Protected resources this agent requires authentication for. - * + * * Each entry describes an OAuth 2.0 protected resource using * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) semantics. * Clients should obtain tokens from the declared `authorization_servers` @@ -781,7 +786,7 @@ data class AgentInfo( val protectedResources: List? = null, /** * Customizations associated with this agent. - * + * * Either container customizations — * {@link PluginCustomization | `PluginCustomization`} entries the agent * bundles, plus {@link DirectoryCustomization | `DirectoryCustomization`} @@ -829,7 +834,7 @@ data class SessionModelInfo( val configSchema: ConfigSchema? = null, /** * Additional provider-specific metadata for this model. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `pricing` key may carry model pricing metadata. */ @@ -981,9 +986,9 @@ data class SessionState( val config: SessionConfigState? = null, /** * Top-level customizations active in this session. - * + * * Always one of the {@link Customization} variants: - * + * * - Container customizations ({@link PluginCustomization}, * {@link DirectoryCustomization}) whose children — agents, skills, * prompts, rules, hooks, MCP servers — live in each container's @@ -992,7 +997,7 @@ data class SessionState( * surfaces directly (for example a globally-configured MCP server * that isn't bundled in a plugin or directory). MCP servers may * also appear as children of a container. - * + * * Client-published plugins arrive via * {@link SessionActiveClient.customizations | `activeClient.customizations`} * and the host propagates them into this list (typically with the @@ -1011,7 +1016,7 @@ data class SessionState( val changesets: List? = null, /** * Additional provider-specific metadata for this session. - * + * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `git` key may provide extra git metadata about the session's * workingDirectory. @@ -1036,7 +1041,7 @@ data class SessionActiveClient( val tools: List, /** * Plugin customizations this client contributes to the session. - * + * * Clients publish in [Open Plugins](https://open-plugins.com/) format * — i.e. always container-shaped plugins. They MAY synthesize virtual * plugins in memory and rely on the host to expand them into concrete @@ -1085,7 +1090,7 @@ data class SessionSummary( val model: ModelSelection? = null, /** * Currently selected custom agent. - * + * * Absent (`undefined`) means no custom agent is selected for this session * — the session uses the provider's default behavior. */ @@ -1100,7 +1105,14 @@ data class SessionSummary( * session's footprint (e.g., for list rendering) without requiring the * client to subscribe to a changeset. */ - val changes: ChangesSummary? = null + val changes: ChangesSummary? = null, + /** + * Lightweight summary of this session's inline annotations channel + * (`ahp-session://annotations`). Surfaced so badge UI can render + * annotation / entry counts without subscribing. Absent when the session + * does not expose an annotations channel. + */ + val annotations: AnnotationsSummary? = null ) @Serializable @@ -1155,7 +1167,7 @@ data class Turn( val message: Message, /** * All response content in stream order: text, tool calls, reasoning, and content refs. - * + * * Consumers should derive display text by concatenating markdown parts, * and find tool calls by filtering for `ToolCall` parts. */ @@ -1186,7 +1198,7 @@ data class ActiveTurn( val message: Message, /** * All response content in stream order: text, tool calls, reasoning, and content refs. - * + * * Tool call parts include `pendingPermissions` when permissions are awaiting user approval. */ val responseParts: List, @@ -1212,7 +1224,7 @@ data class Message( val attachments: List? = null, /** * Additional provider-specific metadata for this message. - * + * * Clients MAY look for well-known keys here to provide enhanced UI, and * agent hosts MAY use it to carry context that does not fit any other * field. Mirrors the MCP `_meta` convention. @@ -1535,20 +1547,20 @@ data class SimpleMessageAttachment( /** * Advisory display hint for clients rendering this attachment. Recognized * values include: - * + * * - `'image'`: the attachment is an image * - `'document'`: the attachment is a textual document * - `'symbol'`: the attachment is a code symbol (e.g. a function or class) * - `'directory'`: the attachment is a folder * - `'selection'`: the attachment is a selection within a document - * + * * Implementations MAY provide additional values; clients SHOULD fall back * to a reasonable default when an unknown value is encountered. */ val displayKind: String? = null, /** * Additional implementation-defined metadata for the attachment. - * + * * If the attachment was produced by the `completions` command, the client * MUST preserve every property of `_meta` originally returned by the agent * host when sending the user message containing the accepted completion. @@ -1561,7 +1573,7 @@ data class SimpleMessageAttachment( val type: MessageAttachmentKind, /** * Representation of the attachment as it should be shown to the model. - * + * * If the attachment was produced by the client, this property MUST be * defined so the agent host can correctly interpret the attachment. This * property MAY be omitted when the attachment originated from a @@ -1585,20 +1597,20 @@ data class MessageEmbeddedResourceAttachment( /** * Advisory display hint for clients rendering this attachment. Recognized * values include: - * + * * - `'image'`: the attachment is an image * - `'document'`: the attachment is a textual document * - `'symbol'`: the attachment is a code symbol (e.g. a function or class) * - `'directory'`: the attachment is a folder * - `'selection'`: the attachment is a selection within a document - * + * * Implementations MAY provide additional values; clients SHOULD fall back * to a reasonable default when an unknown value is encountered. */ val displayKind: String? = null, /** * Additional implementation-defined metadata for the attachment. - * + * * If the attachment was produced by the `completions` command, the client * MUST preserve every property of `_meta` originally returned by the agent * host when sending the user message containing the accepted completion. @@ -1619,7 +1631,7 @@ data class MessageEmbeddedResourceAttachment( val contentType: String, /** * Optional selection within the attached textual resource. - * + * * Only meaningful for textual resources. */ val selection: TextSelection? = null @@ -1640,20 +1652,20 @@ data class MessageResourceAttachment( /** * Advisory display hint for clients rendering this attachment. Recognized * values include: - * + * * - `'image'`: the attachment is an image * - `'document'`: the attachment is a textual document * - `'symbol'`: the attachment is a code symbol (e.g. a function or class) * - `'directory'`: the attachment is a folder * - `'selection'`: the attachment is a selection within a document - * + * * Implementations MAY provide additional values; clients SHOULD fall back * to a reasonable default when an unknown value is encountered. */ val displayKind: String? = null, /** * Additional implementation-defined metadata for the attachment. - * + * * If the attachment was produced by the `completions` command, the client * MUST preserve every property of `_meta` originally returned by the agent * host when sending the user message containing the accepted completion. @@ -1678,12 +1690,63 @@ data class MessageResourceAttachment( val type: MessageAttachmentKind, /** * Optional selection within the referenced textual resource. - * + * * Only meaningful for textual resources. */ val selection: TextSelection? = null ) +@Serializable +data class MessageAnnotationsAttachment( + /** + * A human-readable label for the attachment (e.g. the filename of a file + * attachment). Used for display in UI. + */ + val label: String, + /** + * If defined, the range in {@link Message.text} that references this + * attachment. This is a text range, not a byte range. + */ + val range: TextRange? = null, + /** + * Advisory display hint for clients rendering this attachment. Recognized + * values include: + * + * - `'image'`: the attachment is an image + * - `'document'`: the attachment is a textual document + * - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + * - `'directory'`: the attachment is a folder + * - `'selection'`: the attachment is a selection within a document + * + * Implementations MAY provide additional values; clients SHOULD fall back + * to a reasonable default when an unknown value is encountered. + */ + val displayKind: String? = null, + /** + * Additional implementation-defined metadata for the attachment. + * + * If the attachment was produced by the `completions` command, the client + * MUST preserve every property of `_meta` originally returned by the agent + * host when sending the user message containing the accepted completion. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Discriminant + */ + val type: MessageAttachmentKind, + /** + * The annotations channel URI (typically `ahp-session://annotations`). + * Matches {@link AnnotationsSummary.resource}. + */ + val resource: String, + /** + * Specific {@link Annotation.id | annotation ids} to reference. When + * omitted, the attachment references all annotations on the channel. + */ + val annotationIds: List? = null +) + @Serializable data class MarkdownResponsePart( /** @@ -1788,13 +1851,13 @@ data class ToolCallResult( val pastTenseMessage: StringOrMarkdown, /** * Unstructured result content blocks. - * + * * This mirrors the `content` field of MCP `CallToolResult`. */ val content: List? = null, /** * Optional structured result object. - * + * * This mirrors the `structuredContent` field of MCP `CallToolResult`. */ val structuredContent: Map? = null, @@ -1824,7 +1887,7 @@ data class ToolCallStreamingState( val contributor: ToolCallContributor? = null, /** * Additional provider-specific metadata for this tool call. - * + * * This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) * `McpUiToolMeta` found in MCP tool calls, which may be used in combination * with the {@link contributor} to serve MCP Apps. @@ -1862,7 +1925,7 @@ data class ToolCallPendingConfirmationState( val contributor: ToolCallContributor? = null, /** * Additional provider-specific metadata for this tool call. - * + * * This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) * `McpUiToolMeta` found in MCP tool calls, which may be used in combination * with the {@link contributor} to serve MCP Apps. @@ -1919,7 +1982,7 @@ data class ToolCallRunningState( val contributor: ToolCallContributor? = null, /** * Additional provider-specific metadata for this tool call. - * + * * This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) * `McpUiToolMeta` found in MCP tool calls, which may be used in combination * with the {@link contributor} to serve MCP Apps. @@ -1945,7 +2008,7 @@ data class ToolCallRunningState( val selectedOption: ConfirmationOption? = null, /** * Partial content produced while the tool is still executing. - * + * * For example, a terminal content block lets clients subscribe to live * output before the tool completes. */ @@ -1972,7 +2035,7 @@ data class ToolCallPendingResultConfirmationState( val contributor: ToolCallContributor? = null, /** * Additional provider-specific metadata for this tool call. - * + * * This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) * `McpUiToolMeta` found in MCP tool calls, which may be used in combination * with the {@link contributor} to serve MCP Apps. @@ -1997,13 +2060,13 @@ data class ToolCallPendingResultConfirmationState( val pastTenseMessage: StringOrMarkdown, /** * Unstructured result content blocks. - * + * * This mirrors the `content` field of MCP `CallToolResult`. */ val content: List? = null, /** * Optional structured result object. - * + * * This mirrors the `structuredContent` field of MCP `CallToolResult`. */ val structuredContent: Map? = null, @@ -2042,7 +2105,7 @@ data class ToolCallCompletedState( val contributor: ToolCallContributor? = null, /** * Additional provider-specific metadata for this tool call. - * + * * This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) * `McpUiToolMeta` found in MCP tool calls, which may be used in combination * with the {@link contributor} to serve MCP Apps. @@ -2067,13 +2130,13 @@ data class ToolCallCompletedState( val pastTenseMessage: StringOrMarkdown, /** * Unstructured result content blocks. - * + * * This mirrors the `content` field of MCP `CallToolResult`. */ val content: List? = null, /** * Optional structured result object. - * + * * This mirrors the `structuredContent` field of MCP `CallToolResult`. */ val structuredContent: Map? = null, @@ -2112,7 +2175,7 @@ data class ToolCallCancelledState( val contributor: ToolCallContributor? = null, /** * Additional provider-specific metadata for this tool call. - * + * * This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) * `McpUiToolMeta` found in MCP tool calls, which may be used in combination * with the {@link contributor} to serve MCP Apps. @@ -2162,7 +2225,7 @@ data class ConfirmationOption( val kind: ConfirmationOptionKind, /** * Logical group number for visual categorisation. - * + * * Clients SHOULD display options in the order they are defined and MAY * use differing group numbers to insert dividers between logical clusters * of options. @@ -2186,14 +2249,14 @@ data class ToolDefinition( val description: String? = null, /** * JSON Schema defining the expected input parameters. - * + * * Optional because client-provided tools may not have formal schemas. * Mirrors MCP `Tool.inputSchema`. */ val inputSchema: JsonElement? = null, /** * JSON Schema defining the structure of the tool's output. - * + * * Mirrors MCP `Tool.outputSchema`. */ val outputSchema: JsonElement? = null, @@ -2203,7 +2266,7 @@ data class ToolDefinition( val annotations: ToolAnnotations? = null, /** * Additional provider-specific metadata. - * + * * Mirrors the MCP `_meta` convention. */ @SerialName("_meta") @@ -2363,7 +2426,7 @@ data class PluginCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2401,7 +2464,7 @@ data class PluginCustomization( val load: CustomizationLoadState? = null, /** * Children discovered inside this container. - * + * * Absent means the host has not parsed this container yet. An empty * array means the host parsed the container and it contributes * nothing. @@ -2421,7 +2484,7 @@ data class ClientPluginCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2459,7 +2522,7 @@ data class ClientPluginCustomization( val load: CustomizationLoadState? = null, /** * Children discovered inside this container. - * + * * Absent means the host has not parsed this container yet. An empty * array means the host parsed the container and it contributes * nothing. @@ -2483,7 +2546,7 @@ data class DirectoryCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2521,7 +2584,7 @@ data class DirectoryCustomization( val load: CustomizationLoadState? = null, /** * Children discovered inside this container. - * + * * Absent means the host has not parsed this container yet. An empty * array means the host parsed the container and it contributes * nothing. @@ -2549,7 +2612,7 @@ data class AgentCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2579,7 +2642,7 @@ data class AgentCustomization( val description: String? = null, /** * Additional provider-specific metadata for this custom agent. - * + * * Mirrors the MCP `_meta` convention. */ @SerialName("_meta") @@ -2597,7 +2660,7 @@ data class SkillCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2644,7 +2707,7 @@ data class PromptCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2684,7 +2747,7 @@ data class RuleCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2735,7 +2798,7 @@ data class HookCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2771,7 +2834,7 @@ data class McpServerCustomization( /** * Source URI for this customization. A plugin URL, a file URI, or a * directory URI. - * + * * For declarations that live inside a larger file — e.g. an MCP * server declared inline in a `plugins.json` manifest — `uri` points * to the containing file and {@link CustomizationBase.range | `range`} @@ -2807,12 +2870,12 @@ data class McpServerCustomization( * into the upstream MCP server itself. The channel is NOT a fresh raw MCP * connection: it piggybacks on the AHP transport * and skips the MCP `initialize` sequence. - * + * * The agent host MAY only serve a subset of MCP on this * channel; the served subset is described by domain-specific * capabilities such as those in * {@link McpServerCustomizationApps.capabilities}. - * + * * The channel URI SHOULD be stable across the server's lifetime, but * the agent host MAY change it (for example across a restart) and * MAY only expose it while the server is in @@ -2917,7 +2980,7 @@ data class ToolCallClientContributor( /** * If this tool is provided by a client, the `clientId` of the owning client. * Absent for server-side tools. - * + * * When set, the identified client is responsible for executing the tool and * dispatching `session/toolCallComplete` with the result. */ @@ -3021,10 +3084,10 @@ data class TerminalState( val rows: Long? = null, /** * Typed content parts, replacing the flat `content: string`. - * + * * Naive consumers that only need the raw VT stream can reconstruct it with: * `content.map(p => p.type === 'command' ? p.output : p.value).join('')` - * + * * Consumers that need command boundaries can filter by part type. */ val content: List, @@ -3039,7 +3102,7 @@ data class TerminalState( /** * Whether this terminal emits `terminal/commandExecuted` and * `terminal/commandFinished` actions and populates `command`-typed parts. - * + * * Clients MUST check this flag before relying on command detection. * Do NOT use the presence of a `command` part as a feature flag — parts * are absent in the normal idle state. @@ -3159,17 +3222,17 @@ data class Changeset( * RFC 6570 URI template. Clients parse the variables directly out of the * template using the standard `{name}` syntax — they are not redeclared * here. - * + * * Only the following template shapes are defined by this protocol; any * other variable name MUST be ignored by clients (there is no * protocol-defined way to obtain values for unknown variables): - * + * * | Variables in template | Meaning | * | ------------------------------------------- | ------------------------------------------------------------------------------------ | * | _(none)_ | A static, session-wide changeset. The template is itself a subscribable URI. | * | `{turnId}` | Per-turn slice. Expand with a `Turn.id` from the session. | * | `{originalTurnId}` and `{modifiedTurnId}` | Diff between two turns. Both variables MUST be present. | - * + * * Future protocol versions MAY add new well-known variables. */ val uriTemplate: String, @@ -3181,7 +3244,7 @@ data class Changeset( * Advisory hint describing what kind of changeset this is, so clients can * group, sort, or render an appropriate icon without parsing * {@link uriTemplate}. Recognized values include: - * + * * - `'session'`: a static, session-wide changeset covering all changes the * agent has produced in this session. * - `'branch'`: changes relative to a base branch (e.g. a feature branch @@ -3192,7 +3255,7 @@ data class Changeset( * - `'compare-turns'`: a diff between two turns. Typically paired with * `{originalTurnId}` and `{modifiedTurnId}` variables in * {@link uriTemplate}. - * + * * Implementations MAY provide additional values; clients SHOULD fall back * to a reasonable default when an unknown value is encountered. */ @@ -3277,7 +3340,7 @@ data class ChangesetOperation( * is in flight, {@link ChangesetOperationStatus.Error | Error} when the * most recent invocation failed, and * {@link ChangesetOperationStatus.Idle | Idle} otherwise. - * + * * Clients SHOULD reflect this state in the UI — e.g. disabling the * control or showing a spinner while `Running`, and surfacing * {@link error} while `Error`. @@ -3290,25 +3353,115 @@ data class ChangesetOperation( val error: ErrorInfo? = null ) +@Serializable +data class AnnotationsSummary( + /** + * The subscribable annotations channel URI for the owning session + * (typically `ahp-session://annotations`). Surfaced explicitly even + * though it is derivable from the session URI so badge UI does not need + * to know the derivation rule. + */ + val resource: String, + /** + * Total number of {@link Annotation} entries in the channel. + */ + val annotationCount: Long, + /** + * Total number of {@link AnnotationEntry} entries across every annotation. + */ + val entryCount: Long +) + +@Serializable +data class AnnotationsState( + /** + * Annotations in this channel, keyed by {@link Annotation.id}. + */ + val annotations: List +) + +@Serializable +data class Annotation( + /** + * Stable identifier within the annotations channel. Assigned by the client + * that dispatches the creating {@link AnnotationsSetAction}. + */ + val id: String, + /** + * Turn that produced the file versions this annotation is anchored to. + * Matches a {@link Turn.id} on the owning session. + */ + val turnId: String, + /** + * The file the annotation is anchored to. + */ + val resource: String, + /** + * Range within {@link resource} the annotation is anchored to. When + * omitted the annotation is anchored to the entire file. + */ + val range: TextRange? = null, + /** + * Whether the annotation has been resolved. Newly created annotations are + * always unresolved (`false`); a client marks an annotation resolved (or + * re-opens it) by dispatching an {@link AnnotationsSetAction} carrying the + * updated flag. + */ + val resolved: Boolean, + /** + * Entries in this annotation, in dispatch order (oldest first). MUST + * contain at least one entry. + */ + val entries: List, + /** + * Producer-defined opaque metadata, surfaced to tooling but not + * interpreted by the protocol. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class AnnotationEntry( + /** + * Stable identifier within the enclosing annotation. Assigned by the client + * that dispatches the {@link AnnotationsEntrySetAction} (or the enclosing + * {@link AnnotationsSetAction}) introducing the entry. + */ + val id: String, + /** + * Entry body. A bare `string` is rendered as plain text; pass + * `{ markdown: "…" }` to opt into Markdown rendering. See + * {@link StringOrMarkdown}. + */ + val text: StringOrMarkdown, + /** + * Producer-defined opaque metadata, surfaced to tooling but not + * interpreted by the protocol. + */ + @SerialName("_meta") + val meta: Map? = null +) + @Serializable data class TelemetryCapabilities( /** * Channel URI (or RFC 6570 URI template) for OTLP log records * (`otlp/exportLogs` notifications). - * + * * The following template variables are defined by this protocol; any * other variable name MUST be ignored by clients (there is no * protocol-defined way to obtain values for unknown variables): - * + * * | Variables in template | Meaning | * | --------------------- | ------------------------------------------------------------------------------------------------------- | * | _(none)_ | The host does not support subscriber-side severity filtering. The template is itself a subscribable URI. | * | `{level}` | Minimum OTLP severity to deliver. Expand to one of the [OTLP `SeverityNumber`](https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber) short names (case-insensitive): `trace`, `debug`, `info`, `warn`, `error`, `fatal`. The server delivers log records whose `severityNumber` falls in the corresponding band or above. | - * + * * Hosts SHOULD honour the expanded `{level}`; clients MUST still filter * defensively in case a host ignores the parameter. Hosts that do not * advertise `{level}` deliver all severities. - * + * * Future protocol versions MAY add new well-known variables (e.g. scope * or attribute filters). */ @@ -3773,6 +3926,8 @@ value class MessageAttachmentSimple(val value: SimpleMessageAttachment) : Messag value class MessageAttachmentEmbeddedResource(val value: MessageEmbeddedResourceAttachment) : MessageAttachment @JvmInline value class MessageAttachmentResource(val value: MessageResourceAttachment) : MessageAttachment +@JvmInline +value class MessageAttachmentAnnotations(val value: MessageAnnotationsAttachment) : MessageAttachment /** * Forward-compat catch-all for unknown MessageAttachment discriminators. * @@ -3800,6 +3955,7 @@ internal object MessageAttachmentSerializer : KSerializer { "simple" -> MessageAttachmentSimple(input.json.decodeFromJsonElement(SimpleMessageAttachment.serializer(), element)) "embeddedResource" -> MessageAttachmentEmbeddedResource(input.json.decodeFromJsonElement(MessageEmbeddedResourceAttachment.serializer(), element)) "resource" -> MessageAttachmentResource(input.json.decodeFromJsonElement(MessageResourceAttachment.serializer(), element)) + "annotations" -> MessageAttachmentAnnotations(input.json.decodeFromJsonElement(MessageAnnotationsAttachment.serializer(), element)) else -> MessageAttachmentUnknown(obj) } } @@ -3811,6 +3967,7 @@ internal object MessageAttachmentSerializer : KSerializer { is MessageAttachmentSimple -> output.json.encodeToJsonElement(SimpleMessageAttachment.serializer(), value.value) is MessageAttachmentEmbeddedResource -> output.json.encodeToJsonElement(MessageEmbeddedResourceAttachment.serializer(), value.value) is MessageAttachmentResource -> output.json.encodeToJsonElement(MessageResourceAttachment.serializer(), value.value) + is MessageAttachmentAnnotations -> output.json.encodeToJsonElement(MessageAnnotationsAttachment.serializer(), value.value) is MessageAttachmentUnknown -> value.raw } output.encodeJsonElement(element) @@ -4160,7 +4317,8 @@ internal object ToolResultContentSerializer : KSerializer { } /** - * The state payload of a snapshot — root, session, terminal, or changeset state. + * The state payload of a snapshot — root, session, terminal, changeset, + * or annotations state. */ @Serializable(with = SnapshotStateSerializer::class) sealed interface SnapshotState { @@ -4168,6 +4326,7 @@ sealed interface SnapshotState { @JvmInline value class Session(val value: SessionState) : SnapshotState @JvmInline value class Terminal(val value: TerminalState) : SnapshotState @JvmInline value class Changeset(val value: ChangesetState) : SnapshotState + @JvmInline value class Annotations(val value: AnnotationsState) : SnapshotState } internal object SnapshotStateSerializer : KSerializer { @@ -4182,12 +4341,14 @@ internal object SnapshotStateSerializer : KSerializer { ?: error("Expected JsonObject for SnapshotState") // Try the most distinctive shape first. SessionState has required // `summary`; ChangesetState has required `status` + `files`; - // TerminalState has `uri` / `size` / `buffer`; RootState is the - // catch-all. + // AnnotationsState has required `annotations`; TerminalState has `uri` + // / `size` / `buffer`; RootState is the catch-all. return when { obj.containsKey("summary") -> SnapshotState.Session(input.json.decodeFromJsonElement(SessionState.serializer(), element)) obj.containsKey("status") && obj.containsKey("files") -> SnapshotState.Changeset(input.json.decodeFromJsonElement(ChangesetState.serializer(), element)) + obj.containsKey("annotations") -> + SnapshotState.Annotations(input.json.decodeFromJsonElement(AnnotationsState.serializer(), element)) obj.containsKey("size") || obj.containsKey("uri") || obj.containsKey("buffer") -> SnapshotState.Terminal(input.json.decodeFromJsonElement(TerminalState.serializer(), element)) else -> SnapshotState.Root(input.json.decodeFromJsonElement(RootState.serializer(), element)) @@ -4202,6 +4363,7 @@ internal object SnapshotStateSerializer : KSerializer { is SnapshotState.Session -> output.json.encodeToJsonElement(SessionState.serializer(), value.value) is SnapshotState.Terminal -> output.json.encodeToJsonElement(TerminalState.serializer(), value.value) is SnapshotState.Changeset -> output.json.encodeToJsonElement(ChangesetState.serializer(), value.value) + is SnapshotState.Annotations -> output.json.encodeToJsonElement(AnnotationsState.serializer(), value.value) } output.encodeJsonElement(element) } diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt index d426b79c..229a69c0 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt @@ -1,6 +1,7 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.ChangesetState +import com.microsoft.agenthostprotocol.generated.AnnotationsState import com.microsoft.agenthostprotocol.generated.ResourceWatchState import com.microsoft.agenthostprotocol.generated.RootState import com.microsoft.agenthostprotocol.generated.SessionState @@ -179,6 +180,18 @@ class FixtureDrivenReducerTest { }, ) + "annotations" -> compareFixture( + file = file, + initial = initial, + expected = expected, + serializer = AnnotationsState.serializer(), + run = { state -> + var s = state + for (action in actions) s = annotationsReducer(s, action) + s + }, + ) + "resourceWatch" -> compareFixture( file = file, initial = initial, diff --git a/clients/rust/CHANGELOG.md b/clients/rust/CHANGELOG.md index 0b7905bb..545446a9 100644 --- a/clients/rust/CHANGELOG.md +++ b/clients/rust/CHANGELOG.md @@ -50,6 +50,18 @@ Implements AHP 0.3.0. `idle → running → error` lifecycle of a changeset operation. - `AgentCustomization._meta` provider metadata field. - Optional `changes` field on `SessionSummary` (`ChangesSummary` with optional `additions`, `deletions`, and `files` counts) summarising a session's file-change footprint. +- New annotations channel wire types (`ahp-session://annotations`): + `AnnotationsState`, `Annotation`, `AnnotationEntry`, + `AnnotationsSummary`; the client-dispatchable + `annotations/set` / `annotations/removed` / `annotations/entrySet` + / `annotations/entryRemoved` action variants — clients drive every annotation + mutation by dispatching these directly, assigning the `Annotation.id` / + `AnnotationEntry.id` themselves; + `MultiHostStateMirror.annotations()` and `SnapshotState::Annotations`. + Reducer logic is deferred (matches the changeset stub). +- `MessageAnnotationsAttachment` (`annotations` `MessageAttachment` variant) + referencing annotations on a session's annotations channel by `resource` + URI, optionally narrowed to an `annotationIds` array. ### Changed diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index ff321a56..627b6d68 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -12,12 +12,13 @@ use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::state::{ - AgentInfo, AgentSelection, Changeset, ChangesetFile, ChangesetOperation, - ChangesetOperationStatus, ChangesetStatus, ConfirmationOption, Customization, ErrorInfo, - McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, - SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, TerminalClaim, TerminalInfo, - ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, - ToolDefinition, ToolResultContent, UsageInfo, + AgentInfo, AgentSelection, Annotation, AnnotationEntry, Changeset, ChangesetFile, + ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ConfirmationOption, + Customization, ErrorInfo, McpServerState, Message, ModelSelection, PendingMessageKind, + ResponsePart, SessionActiveClient, SessionInputAnswer, SessionInputRequest, + SessionInputResponseKind, TerminalClaim, TerminalInfo, ToolCallCancellationReason, + ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, ToolDefinition, + ToolResultContent, UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -123,6 +124,14 @@ pub enum ActionType { ChangesetOperationStatusChanged, #[serde(rename = "changeset/cleared")] ChangesetCleared, + #[serde(rename = "annotations/set")] + AnnotationsSet, + #[serde(rename = "annotations/removed")] + AnnotationsRemoved, + #[serde(rename = "annotations/entrySet")] + AnnotationsEntrySet, + #[serde(rename = "annotations/entryRemoved")] + AnnotationsEntryRemoved, #[serde(rename = "root/terminalsChanged")] RootTerminalsChanged, #[serde(rename = "root/configChanged")] @@ -967,6 +976,67 @@ pub struct ChangesetOperationStatusChangedAction { #[serde(rename_all = "camelCase")] pub struct ChangesetClearedAction {} +/// Upsert an {@link Annotation} in the annotations channel — adds a new +/// annotation, or replaces an existing one identified by +/// {@link Annotation.id}. +/// +/// Dispatched by a client to create an annotation (together with its +/// mandatory first entry) or to re-anchor / resolve an existing one; the +/// dispatching client assigns the {@link Annotation.id} and the id of any +/// new entry. When replacing, the full annotation payload (including its +/// {@link Annotation.entries | entries} list) is substituted; producers +/// SHOULD prefer {@link AnnotationsEntrySetAction} for per-entry edits to +/// keep wire updates small. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationsSetAction { + /// The new or replacement annotation. MUST contain at least one entry. + pub annotation: Annotation, +} + +/// Remove an {@link Annotation} from the channel by its id. +/// +/// Dispatched to delete an entire annotation and every entry it contains. +/// Because the protocol forbids empty annotations, a client that wants to +/// remove the last remaining entry dispatches this action — collapsing the +/// annotation — rather than {@link AnnotationsEntryRemovedAction}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationsRemovedAction { + /// The {@link Annotation.id} of the annotation to remove. + pub annotation_id: String, +} + +/// Upsert an {@link AnnotationEntry} within an existing annotation — adds a +/// new entry, or replaces one identified by {@link AnnotationEntry.id}. The +/// dispatching client assigns the {@link AnnotationEntry.id} of a new entry. +/// If {@link annotationId} does not match any current annotation the action +/// is a no-op. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationsEntrySetAction { + /// The {@link Annotation.id} the entry belongs to. + pub annotation_id: String, + /// The new or replacement entry. + pub entry: AnnotationEntry, +} + +/// Remove a single {@link AnnotationEntry} from an annotation without +/// collapsing the annotation itself. Used when more than one entry remains — +/// to remove the last entry a client dispatches {@link AnnotationsRemovedAction} +/// instead, since the protocol forbids empty annotations. +/// +/// If either {@link annotationId} or {@link entryId} does not match the +/// current state the action is a no-op. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationsEntryRemovedAction { + /// The {@link Annotation.id} the entry belongs to. + pub annotation_id: String, + /// The {@link AnnotationEntry.id} to remove. + pub entry_id: String, +} + /// Fired when the list of known terminals changes. /// /// Full-replacement semantics: the `terminals` array replaces the previous @@ -1231,6 +1301,14 @@ pub enum StateAction { ChangesetOperationStatusChanged(ChangesetOperationStatusChangedAction), #[serde(rename = "changeset/cleared")] ChangesetCleared(ChangesetClearedAction), + #[serde(rename = "annotations/set")] + AnnotationsSet(AnnotationsSetAction), + #[serde(rename = "annotations/removed")] + AnnotationsRemoved(AnnotationsRemovedAction), + #[serde(rename = "annotations/entrySet")] + AnnotationsEntrySet(AnnotationsEntrySetAction), + #[serde(rename = "annotations/entryRemoved")] + AnnotationsEntryRemoved(AnnotationsEntryRemovedAction), #[serde(rename = "root/terminalsChanged")] RootTerminalsChanged(RootTerminalsChangedAction), #[serde(rename = "terminal/data")] diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 50a1a199..a87e1626 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -17,7 +17,7 @@ use crate::actions::{ActionEnvelope, StateAction}; use crate::state::{ AgentSelection, ContentRef, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, - TerminalClaim, Turn, + TerminalClaim, TextRange, Turn, }; // ─── Enums ──────────────────────────────────────────────────────────── diff --git a/clients/rust/crates/ahp-types/src/notifications.rs b/clients/rust/crates/ahp-types/src/notifications.rs index f1a55a22..826f3bef 100644 --- a/clients/rust/crates/ahp-types/src/notifications.rs +++ b/clients/rust/crates/ahp-types/src/notifications.rs @@ -13,8 +13,8 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; #[allow(unused_imports)] use crate::state::{ - AgentSelection, ChangesSummary, Changeset, FileEdit, ModelSelection, ProjectInfo, - SessionStatus, SessionSummary, + AgentSelection, AnnotationsSummary, ChangesSummary, Changeset, FileEdit, ModelSelection, + ProjectInfo, SessionStatus, SessionSummary, }; // ─── Enums ──────────────────────────────────────────────────────────── @@ -223,4 +223,10 @@ pub struct PartialSessionSummary { /// client to subscribe to a changeset. #[serde(default, skip_serializing_if = "Option::is_none")] pub changes: Option, + /// Lightweight summary of this session's inline annotations channel + /// (`ahp-session://annotations`). Surfaced so badge UI can render + /// annotation / entry counts without subscribing. Absent when the session + /// does not expose an annotations channel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub annotations: Option, } diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index b8065e8b..8dffb43b 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -145,6 +145,9 @@ pub enum MessageAttachmentKind { /// An attachment that references a resource by URI. #[serde(rename = "resource")] Resource, + /// An attachment that references annotations on an annotations channel. + #[serde(rename = "annotations")] + Annotations, } /// Discriminant for response part types. @@ -875,6 +878,12 @@ pub struct SessionSummary { /// client to subscribe to a changeset. #[serde(default, skip_serializing_if = "Option::is_none")] pub changes: Option, + /// Lightweight summary of this session's inline annotations channel + /// (`ahp-session://annotations`). Surfaced so badge UI can render + /// annotation / entry counts without subscribing. Absent when the session + /// does not expose an annotations channel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub annotations: Option, } /// Aggregate counts describing the file changes associated with a session. @@ -1424,6 +1433,51 @@ pub struct MessageResourceAttachment { pub selection: Option, } +/// An attachment that references annotations on a session's annotations +/// channel (see {@link AnnotationsState}). +/// +/// When {@link annotationIds} is omitted the attachment references every +/// annotation on the channel; when present it references only the listed +/// {@link Annotation.id | annotation ids}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageAnnotationsAttachment { + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + pub label: String, + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range: Option, + /// Advisory display hint for clients rendering this attachment. Recognized + /// values include: + /// + /// - `'image'`: the attachment is an image + /// - `'document'`: the attachment is a textual document + /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + /// - `'directory'`: the attachment is a folder + /// - `'selection'`: the attachment is a selection within a document + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_kind: Option, + /// Additional implementation-defined metadata for the attachment. + /// + /// If the attachment was produced by the `completions` command, the client + /// MUST preserve every property of `_meta` originally returned by the agent + /// host when sending the user message containing the accepted completion. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// The annotations channel URI (typically `ahp-session://annotations`). + /// Matches {@link AnnotationsSummary.resource}. + pub resource: Uri, + /// Specific {@link Annotation.id | annotation ids} to reference. When + /// omitted, the attachment references all annotations on the channel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub annotation_ids: Option>, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MarkdownResponsePart { @@ -2831,6 +2885,93 @@ pub struct ChangesetOperation { pub error: Option, } +/// Lightweight per-session summary of the annotations channel, surfaced on +/// {@link SessionSummary.annotations} so badge UI can render annotation / +/// entry counts without subscribing to the channel itself. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationsSummary { + /// The subscribable annotations channel URI for the owning session + /// (typically `ahp-session://annotations`). Surfaced explicitly even + /// though it is derivable from the session URI so badge UI does not need + /// to know the derivation rule. + pub resource: Uri, + /// Total number of {@link Annotation} entries in the channel. + pub annotation_count: i64, + /// Total number of {@link AnnotationEntry} entries across every annotation. + pub entry_count: i64, +} + +/// Full state for a session's annotations channel, returned when a client +/// subscribes to an `ahp-session://annotations` URI. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationsState { + /// Annotations in this channel, keyed by {@link Annotation.id}. + pub annotations: Vec, +} + +/// A conversation anchored to a specific file produced by a specific turn, +/// optionally narrowed to a range within that file. +/// +/// {@link turnId} anchors the annotation to the file versions that turn +/// produced, so a later turn that rewrites the same file does not silently +/// invalidate the annotation's anchor — clients can resolve {@link resource} +/// and {@link range} against the turn's changeset. When {@link range} is +/// omitted the annotation is anchored to the entire file. +/// +/// Every annotation MUST contain at least one {@link AnnotationEntry}. An +/// {@link AnnotationsSetAction} that creates an annotation therefore carries +/// its mandatory first entry, and removing the last remaining entry collapses +/// the annotation via {@link AnnotationsRemovedAction} rather than leaving an +/// empty annotation behind. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Annotation { + /// Stable identifier within the annotations channel. Assigned by the client + /// that dispatches the creating {@link AnnotationsSetAction}. + pub id: String, + /// Turn that produced the file versions this annotation is anchored to. + /// Matches a {@link Turn.id} on the owning session. + pub turn_id: String, + /// The file the annotation is anchored to. + pub resource: Uri, + /// Range within {@link resource} the annotation is anchored to. When + /// omitted the annotation is anchored to the entire file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range: Option, + /// Whether the annotation has been resolved. Newly created annotations are + /// always unresolved (`false`); a client marks an annotation resolved (or + /// re-opens it) by dispatching an {@link AnnotationsSetAction} carrying the + /// updated flag. + pub resolved: bool, + /// Entries in this annotation, in dispatch order (oldest first). MUST + /// contain at least one entry. + pub entries: Vec, + /// Producer-defined opaque metadata, surfaced to tooling but not + /// interpreted by the protocol. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// A single entry within an {@link Annotation}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationEntry { + /// Stable identifier within the enclosing annotation. Assigned by the client + /// that dispatches the {@link AnnotationsEntrySetAction} (or the enclosing + /// {@link AnnotationsSetAction}) introducing the entry. + pub id: String, + /// Entry body. A bare `string` is rendered as plain text; pass + /// `{ markdown: "…" }` to opt into Markdown rendering. See + /// {@link StringOrMarkdown}. + pub text: StringOrMarkdown, + /// Producer-defined opaque metadata, surfaced to tooling but not + /// interpreted by the protocol. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + /// OTLP telemetry channels the agent host emits. /// /// Each field, when present, is either a literal channel URI or an @@ -3084,6 +3225,8 @@ pub enum MessageAttachment { EmbeddedResource(MessageEmbeddedResourceAttachment), #[serde(rename = "resource")] Resource(MessageResourceAttachment), + #[serde(rename = "annotations")] + Annotations(MessageAnnotationsAttachment), /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. /// Reducers treat this as a no-op. #[serde(untagged)] @@ -3180,17 +3323,19 @@ pub enum ToolCallContributor { Unknown(serde_json::Value), } -/// The state payload of a snapshot — root, session, terminal, or -/// changeset state. +/// The state payload of a snapshot — root, session, terminal, +/// changeset, or annotations state. /// /// Deserialized by trying session first (has required `summary`), then /// terminal (has required `content`), then changeset (has required -/// `status` and `files`), then root. +/// `status` and `files`), then annotations (has required `annotations`), +/// then root. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum SnapshotState { Session(Box), Terminal(Box), Changeset(Box), + Annotations(Box), Root(Box), } diff --git a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs index 793c6d93..83f28c56 100644 --- a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs @@ -36,7 +36,9 @@ use std::collections::HashMap; use ahp_types::actions::ActionEnvelope; use ahp_types::common::ROOT_RESOURCE_URI; -use ahp_types::state::{ChangesetState, RootState, SessionState, SnapshotState, TerminalState}; +use ahp_types::state::{ + AnnotationsState, ChangesetState, RootState, SessionState, SnapshotState, TerminalState, +}; use crate::hosts::{HostId, HostSubscriptionEvent}; use crate::reducers::{apply_action_to_root, apply_action_to_session, apply_action_to_terminal}; @@ -84,6 +86,7 @@ pub struct MultiHostStateMirror { sessions: HashMap, terminals: HashMap, changesets: HashMap, + annotations: HashMap, } impl MultiHostStateMirror { @@ -112,6 +115,11 @@ impl MultiHostStateMirror { &self.changesets } + /// Borrow the annotations states map keyed by `(host_id, uri)`. + pub fn annotations(&self) -> &HashMap { + &self.annotations + } + /// Convenience: apply a [`HostSubscriptionEvent`] produced by /// [`crate::hosts::MultiHostClient::events`]. Action envelopes are /// routed through the reducer; non-action events (session-summary @@ -174,16 +182,20 @@ impl MultiHostStateMirror { SnapshotState::Changeset(state) => { self.changesets.insert(key, state.as_ref().clone()); } + SnapshotState::Annotations(state) => { + self.annotations.insert(key, state.as_ref().clone()); + } } } /// Drop every slot keyed under `host` — root state, sessions, - /// terminals, and changesets. + /// terminals, changesets, and annotations. pub fn reset_host(&mut self, host: &HostId) { self.root_states.remove(host); self.sessions.retain(|key, _| &key.host_id != host); self.terminals.retain(|key, _| &key.host_id != host); self.changesets.retain(|key, _| &key.host_id != host); + self.annotations.retain(|key, _| &key.host_id != host); } /// Drop every host's state. @@ -192,5 +204,6 @@ impl MultiHostStateMirror { self.sessions.clear(); self.terminals.clear(); self.changesets.clear(); + self.annotations.clear(); } } diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 70d2239d..3cf00693 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -1259,6 +1259,7 @@ mod tests { agent: None, working_directory: None, changes: None, + annotations: None, }, lifecycle: SessionLifecycle::Creating, creation_error: None, @@ -1559,6 +1560,11 @@ mod tests { skipped += 1; continue; } + "annotations" => { + // annotations reducer not yet implemented in Rust; skip. + skipped += 1; + continue; + } "resourceWatch" => { // resourceWatch reducer not yet implemented in Rust; skip. skipped += 1; diff --git a/clients/rust/crates/ahp/tests/hosts.rs b/clients/rust/crates/ahp/tests/hosts.rs index cf5e9da7..534b6c06 100644 --- a/clients/rust/crates/ahp/tests/hosts.rs +++ b/clients/rust/crates/ahp/tests/hosts.rs @@ -1033,6 +1033,7 @@ fn make_summary(uri: &str, title: &str, modified_at: i64) -> ahp_types::state::S agent: None, working_directory: None, changes: None, + annotations: None, } } diff --git a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs index 95bc7d27..6b46b6b8 100644 --- a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs @@ -58,6 +58,7 @@ fn session_state(title: &str, resource: &str) -> SessionState { agent: None, working_directory: None, changes: None, + annotations: None, }, lifecycle: SessionLifecycle::Ready, creation_error: None, @@ -366,6 +367,7 @@ fn non_action_event_is_ignored() { agent: None, working_directory: None, changes: None, + annotations: None, }, }), }; diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index f424bc70..e41d4ac9 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -55,6 +55,10 @@ public enum ActionType: String, Codable, Sendable { case changesetOperationsChanged = "changeset/operationsChanged" case changesetOperationStatusChanged = "changeset/operationStatusChanged" case changesetCleared = "changeset/cleared" + case annotationsSet = "annotations/set" + case annotationsRemoved = "annotations/removed" + case annotationsEntrySet = "annotations/entrySet" + case annotationsEntryRemoved = "annotations/entryRemoved" case rootTerminalsChanged = "root/terminalsChanged" case rootConfigChanged = "root/configChanged" case terminalData = "terminal/data" @@ -231,7 +235,7 @@ public struct SessionToolCallStartAction: Codable, Sendable { /// Tool call identifier public var toolCallId: String /// Additional provider-specific metadata for this tool call. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `ptyTerminal` key with `{ input: string; output: string }` /// indicates the tool operated on a terminal (both `input` and `output` may @@ -281,7 +285,7 @@ public struct SessionToolCallDeltaAction: Codable, Sendable { /// Tool call identifier public var toolCallId: String /// Additional provider-specific metadata for this tool call. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `ptyTerminal` key with `{ input: string; output: string }` /// indicates the tool operated on a terminal (both `input` and `output` may @@ -325,7 +329,7 @@ public struct SessionToolCallReadyAction: Codable, Sendable { /// Tool call identifier public var toolCallId: String /// Additional provider-specific metadata for this tool call. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `ptyTerminal` key with `{ input: string; output: string }` /// indicates the tool operated on a terminal (both `input` and `output` may @@ -454,7 +458,7 @@ public struct SessionToolCallCompleteAction: Codable, Sendable { /// Tool call identifier public var toolCallId: String /// Additional provider-specific metadata for this tool call. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `ptyTerminal` key with `{ input: string; output: string }` /// indicates the tool operated on a terminal (both `input` and `output` may @@ -498,7 +502,7 @@ public struct SessionToolCallResultConfirmedAction: Codable, Sendable { /// Tool call identifier public var toolCallId: String /// Additional provider-specific metadata for this tool call. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `ptyTerminal` key with `{ input: string; output: string }` /// indicates the tool operated on a terminal (both `input` and `output` may @@ -1011,7 +1015,7 @@ public struct SessionToolCallContentChangedAction: Codable, Sendable { /// Tool call identifier public var toolCallId: String /// Additional provider-specific metadata for this tool call. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `ptyTerminal` key with `{ input: string; output: string }` /// indicates the tool operated on a terminal (both `input` and `output` may @@ -1136,6 +1140,70 @@ public struct ChangesetClearedAction: Codable, Sendable { } } +public struct AnnotationsSetAction: Codable, Sendable { + public var type: ActionType + /// The new or replacement annotation. MUST contain at least one entry. + public var annotation: Annotation + + public init( + type: ActionType, + annotation: Annotation + ) { + self.type = type + self.annotation = annotation + } +} + +public struct AnnotationsRemovedAction: Codable, Sendable { + public var type: ActionType + /// The {@link Annotation.id} of the annotation to remove. + public var annotationId: String + + public init( + type: ActionType, + annotationId: String + ) { + self.type = type + self.annotationId = annotationId + } +} + +public struct AnnotationsEntrySetAction: Codable, Sendable { + public var type: ActionType + /// The {@link Annotation.id} the entry belongs to. + public var annotationId: String + /// The new or replacement entry. + public var entry: AnnotationEntry + + public init( + type: ActionType, + annotationId: String, + entry: AnnotationEntry + ) { + self.type = type + self.annotationId = annotationId + self.entry = entry + } +} + +public struct AnnotationsEntryRemovedAction: Codable, Sendable { + public var type: ActionType + /// The {@link Annotation.id} the entry belongs to. + public var annotationId: String + /// The {@link AnnotationEntry.id} to remove. + public var entryId: String + + public init( + type: ActionType, + annotationId: String, + entryId: String + ) { + self.type = type + self.annotationId = annotationId + self.entryId = entryId + } +} + public struct RootTerminalsChangedAction: Codable, Sendable { public var type: ActionType /// Updated terminal list (full replacement) @@ -1404,6 +1472,10 @@ public enum StateAction: Codable, Sendable { case changesetOperationsChanged(ChangesetOperationsChangedAction) case changesetOperationStatusChanged(ChangesetOperationStatusChangedAction) case changesetCleared(ChangesetClearedAction) + case annotationsSet(AnnotationsSetAction) + case annotationsRemoved(AnnotationsRemovedAction) + case annotationsEntrySet(AnnotationsEntrySetAction) + case annotationsEntryRemoved(AnnotationsEntryRemovedAction) case rootTerminalsChanged(RootTerminalsChangedAction) case rootConfigChanged(RootConfigChangedAction) case terminalData(TerminalDataAction) @@ -1525,6 +1597,14 @@ public enum StateAction: Codable, Sendable { self = .changesetOperationStatusChanged(try ChangesetOperationStatusChangedAction(from: decoder)) case "changeset/cleared": self = .changesetCleared(try ChangesetClearedAction(from: decoder)) + case "annotations/set": + self = .annotationsSet(try AnnotationsSetAction(from: decoder)) + case "annotations/removed": + self = .annotationsRemoved(try AnnotationsRemovedAction(from: decoder)) + case "annotations/entrySet": + self = .annotationsEntrySet(try AnnotationsEntrySetAction(from: decoder)) + case "annotations/entryRemoved": + self = .annotationsEntryRemoved(try AnnotationsEntryRemovedAction(from: decoder)) case "root/terminalsChanged": self = .rootTerminalsChanged(try RootTerminalsChangedAction(from: decoder)) case "root/configChanged": @@ -1609,6 +1689,10 @@ public enum StateAction: Codable, Sendable { case .changesetOperationsChanged(let v): try v.encode(to: encoder) case .changesetOperationStatusChanged(let v): try v.encode(to: encoder) case .changesetCleared(let v): try v.encode(to: encoder) + case .annotationsSet(let v): try v.encode(to: encoder) + case .annotationsRemoved(let v): try v.encode(to: encoder) + case .annotationsEntrySet(let v): try v.encode(to: encoder) + case .annotationsEntryRemoved(let v): try v.encode(to: encoder) case .rootTerminalsChanged(let v): try v.encode(to: encoder) case .rootConfigChanged(let v): try v.encode(to: encoder) case .terminalData(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 13b5db17..922d1e6f 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -32,9 +32,9 @@ public enum ResourceType: String, Codable, Sendable { } /// How {@link ResourceWriteParams.data} is placed within the target file. -/// +/// /// Each mode interprets {@link ResourceWriteParams.position} differently: -/// +/// /// - `truncate` (default): rooted at the **start** of the file. The file is /// truncated at `position` (0 by default) and `data` is written from that /// offset, so the resulting file is `existing[0..position] + data`. With @@ -64,7 +64,7 @@ public struct InitializeParams: Codable, Sendable { /// Protocol versions the client is willing to speak, ordered from most /// preferred to least preferred. Each entry is a [SemVer](https://semver.org) /// `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). - /// + /// /// The server selects one entry and returns it as `InitializeResult.protocolVersion`. /// If the server cannot speak any of the offered versions, it MUST return /// error code `-32005` (`UnsupportedProtocolVersion`). @@ -78,7 +78,7 @@ public struct InitializeParams: Codable, Sendable { /// user-facing strings such as confirmation option labels. public var locale: String? /// Optional client capability declarations. - /// + /// /// Servers SHOULD only advertise features whose corresponding client /// capability is set here. Absent means "not declared" — the server /// MUST assume the client does not support the feature. @@ -146,7 +146,7 @@ public struct ClientCapabilities: Codable, Sendable { /// [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e. /// it can host the View sandbox, run the `ui/*` protocol against it, /// and forward `mcp://`-channel traffic on the App's behalf. - /// + /// /// Hosts SHOULD only populate /// {@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`} /// (and expose the corresponding @@ -267,7 +267,7 @@ public struct CreateSessionParams: Codable, Sendable { /// Model selection (ID and optional model-specific configuration) public var model: ModelSelection? /// Initial custom agent selection for the new session. - /// + /// /// Omit to start the session with no custom agent selected (provider default). public var agent: AgentSelection? /// Working directory for the session @@ -279,7 +279,7 @@ public struct CreateSessionParams: Codable, Sendable { /// Keys and values correspond to the schema returned by the server. public var config: [String: AnyCodable]? /// Eagerly claim the active client role for the new session. - /// + /// /// When provided, the server initializes the session with this client as the /// active client, equivalent to dispatching a `session/activeClientChanged` /// action immediately after creation. The `clientId` MUST match the @@ -1101,9 +1101,9 @@ public struct CompletionItem: Codable, Sendable { /// by `insertText`. The range is the half-open interval /// `[rangeStart, rangeEnd)` of character offsets, measured in UTF-16 code /// units. - /// + /// /// When omitted, the client SHOULD insert `insertText` at the cursor. - /// + /// /// Note: this range refers to positions in the *current* input. The /// attachment's own `rangeStart`/`rangeEnd` (when present) refer to /// positions in the final {@link Message.text} after the item is diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift index ffa4adb2..3b220131 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift @@ -70,7 +70,7 @@ public struct PermissionDeniedErrorData: Codable, Sendable { public struct UnsupportedProtocolVersionErrorData: Codable, Sendable { /// Protocol versions the server is willing to speak. - /// + /// /// Each entry is either a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` /// string (e.g. `"0.1.0"`) or a [SemVer range](https://semver.org/#spec-item-11) /// constraint (e.g. `">=0.1.0 <0.3.0"` or `"^0.2.0"`). diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift index e5f9ff00..739e0234 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift @@ -50,7 +50,7 @@ public struct SessionSummaryChangedParams: Codable, Sendable { /// URI of the session whose summary changed public var session: String /// Mutable summary fields that changed; omitted fields are unchanged. - /// + /// /// Identity fields (`resource`, `provider`, `createdAt`) never change and /// MUST be omitted by senders; receivers SHOULD ignore them if present. public var changes: PartialSessionSummary @@ -158,7 +158,7 @@ public struct PartialSessionSummary: Codable, Sendable { /// Currently selected model public var model: ModelSelection? /// Currently selected custom agent. - /// + /// /// Absent (`undefined`) means no custom agent is selected for this session /// — the session uses the provider's default behavior. public var agent: AgentSelection? @@ -169,6 +169,11 @@ public struct PartialSessionSummary: Codable, Sendable { /// session's footprint (e.g., for list rendering) without requiring the /// client to subscribe to a changeset. public var changes: ChangesSummary? + /// Lightweight summary of this session's inline annotations channel + /// (`ahp-session://annotations`). Surfaced so badge UI can render + /// annotation / entry counts without subscribing. Absent when the session + /// does not expose an annotations channel. + public var annotations: AnnotationsSummary? public init( resource: String? = nil, @@ -182,7 +187,8 @@ public struct PartialSessionSummary: Codable, Sendable { model: ModelSelection? = nil, agent: AgentSelection? = nil, workingDirectory: String? = nil, - changes: ChangesSummary? = nil + changes: ChangesSummary? = nil, + annotations: AnnotationsSummary? = nil ) { self.resource = resource self.provider = provider @@ -196,5 +202,6 @@ public struct PartialSessionSummary: Codable, Sendable { self.agent = agent self.workingDirectory = workingDirectory self.changes = changes + self.annotations = annotations } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index d2970c9d..e0c74179 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -63,7 +63,7 @@ public enum SessionLifecycle: String, Codable, Sendable { } /// Bitset of summary-level session status flags. -/// +/// /// Use bitwise checks instead of equality for non-terminal activity. For example, /// `status & SessionStatus.InProgress` matches both ordinary in-progress turns /// and turns that are paused waiting for input. @@ -133,6 +133,8 @@ public enum MessageAttachmentKind: String, Codable, Sendable { case embeddedResource = "embeddedResource" /// An attachment that references a resource by URI. case resource = "resource" + /// An attachment that references annotations on an annotations channel. + case annotations = "annotations" } /// Discriminant for response part types. @@ -155,7 +157,7 @@ public enum ToolCallStatus: String, Codable, Sendable { } /// How a tool call was confirmed for execution. -/// +/// /// - `NotNeeded` — No confirmation required (auto-approved) /// - `UserAction` — User explicitly approved /// - `Setting` — Approved by a persistent user setting @@ -194,7 +196,7 @@ public enum ToolResultContentType: String, Codable, Sendable { } /// Discriminant for the kind of customization. -/// +/// /// Top-level entries in {@link SessionState.customizations} and /// {@link AgentInfo.customizations} are either container customizations /// ({@link CustomizationType.Plugin | `Plugin`} or @@ -256,7 +258,7 @@ public enum McpAuthRequiredReason: String, Codable, Sendable { /// Step-up auth: a token is present but its scopes are insufficient for /// the requested operation (HTTP 403 with /// `WWW-Authenticate: Bearer error="insufficient_scope"`). - /// + /// /// Unlike {@link Required} and {@link Expired} — which typically surface /// before any tool work is in flight — `InsufficientScope` is almost /// always triggered by an MCP request issued mid-turn (a `tools/call`, @@ -284,7 +286,7 @@ public enum ChangesetStatus: String, Codable, Sendable { } /// Execution lifecycle of a {@link ChangesetOperation}. -/// +/// /// An operation is invoked imperatively via `invokeChangesetOperation`, but /// its progress and outcome are reflected back into changeset state so that /// every subscriber observes a consistent view (e.g. a spinner on a "Create @@ -322,10 +324,10 @@ public enum ResourceChangeType: String, Codable, Sendable { public struct Icon: Codable, Sendable { /// A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a /// `data:` URI with Base64-encoded image data. - /// + /// /// Consumers SHOULD take steps to ensure URLs serving icons are from the /// same domain as the client/server or a trusted domain. - /// + /// /// Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain /// executable JavaScript. public var src: String @@ -334,13 +336,13 @@ public struct Icon: Codable, Sendable { public var contentType: String? /// Optional array of strings that specify sizes at which the icon can be used. /// Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. - /// + /// /// If not provided, the client should assume that the icon can be used at any size. public var sizes: [String]? /// Optional specifier for the theme this icon is designed for. `"light"` indicates /// the icon is designed to be used with a light background, and `"dark"` indicates /// the icon is designed to be used with a dark background. - /// + /// /// If not provided, the client should assume the icon can be used with any theme. public var theme: String? @@ -384,13 +386,13 @@ public struct ProtectedResourceMetadata: Codable, Sendable { /// OPTIONAL. URL of the resource's terms of service. public var resourceTosUri: String? /// AHP extension. Whether authentication is required for this resource. - /// + /// /// - `true` (default) — the agent cannot be used without a valid token. /// The server SHOULD return `AuthRequired` (`-32007`) if the client /// attempts to use the agent without authenticating. /// - `false` — the agent works without authentication but MAY offer /// enhanced capabilities when a token is provided. - /// + /// /// Clients SHOULD treat an absent field the same as `true`. public var required: Bool? @@ -451,7 +453,7 @@ public struct RootState: Codable, Sendable { /// Agent host configuration schema and current values public var config: RootConfigState? /// Additional implementation-defined metadata about the agent host itself. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. public var meta: [String: AnyCodable]? @@ -503,7 +505,7 @@ public struct AgentInfo: Codable, Sendable { /// Available models for this agent public var models: [SessionModelInfo] /// Protected resources this agent requires authentication for. - /// + /// /// Each entry describes an OAuth 2.0 protected resource using /// [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) semantics. /// Clients should obtain tokens from the declared `authorization_servers` @@ -511,7 +513,7 @@ public struct AgentInfo: Codable, Sendable { /// with this agent. public var protectedResources: [ProtectedResourceMetadata]? /// Customizations associated with this agent. - /// + /// /// Either container customizations — /// {@link PluginCustomization | `PluginCustomization`} entries the agent /// bundles, plus {@link DirectoryCustomization | `DirectoryCustomization`} @@ -558,7 +560,7 @@ public struct SessionModelInfo: Codable, Sendable { /// {@link ModelSelection.config} when creating or changing sessions. public var configSchema: ConfigSchema? /// Additional provider-specific metadata for this model. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `pricing` key may carry model pricing metadata. public var meta: [String: AnyCodable]? @@ -744,9 +746,9 @@ public struct SessionState: Codable, Sendable { /// Session configuration schema and current values public var config: SessionConfigState? /// Top-level customizations active in this session. - /// + /// /// Always one of the {@link Customization} variants: - /// + /// /// - Container customizations ({@link PluginCustomization}, /// {@link DirectoryCustomization}) whose children — agents, skills, /// prompts, rules, hooks, MCP servers — live in each container's @@ -755,7 +757,7 @@ public struct SessionState: Codable, Sendable { /// surfaces directly (for example a globally-configured MCP server /// that isn't bundled in a plugin or directory). MCP servers may /// also appear as children of a container. - /// + /// /// Client-published plugins arrive via /// {@link SessionActiveClient.customizations | `activeClient.customizations`} /// and the host propagates them into this list (typically with the @@ -770,7 +772,7 @@ public struct SessionState: Codable, Sendable { /// {@link /guide/changesets | Changesets} for an overview of the model. public var changesets: [Changeset]? /// Additional provider-specific metadata for this session. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `git` key may provide extra git metadata about the session's /// workingDirectory. @@ -834,7 +836,7 @@ public struct SessionActiveClient: Codable, Sendable { /// Tools this client provides to the session public var tools: [ToolDefinition] /// Plugin customizations this client contributes to the session. - /// + /// /// Clients publish in [Open Plugins](https://open-plugins.com/) format /// — i.e. always container-shaped plugins. They MAY synthesize virtual /// plugins in memory and rely on the host to expand them into concrete @@ -874,7 +876,7 @@ public struct SessionSummary: Codable, Sendable { /// Currently selected model public var model: ModelSelection? /// Currently selected custom agent. - /// + /// /// Absent (`undefined`) means no custom agent is selected for this session /// — the session uses the provider's default behavior. public var agent: AgentSelection? @@ -885,6 +887,11 @@ public struct SessionSummary: Codable, Sendable { /// session's footprint (e.g., for list rendering) without requiring the /// client to subscribe to a changeset. public var changes: ChangesSummary? + /// Lightweight summary of this session's inline annotations channel + /// (`ahp-session://annotations`). Surfaced so badge UI can render + /// annotation / entry counts without subscribing. Absent when the session + /// does not expose an annotations channel. + public var annotations: AnnotationsSummary? public init( resource: String, @@ -898,7 +905,8 @@ public struct SessionSummary: Codable, Sendable { model: ModelSelection? = nil, agent: AgentSelection? = nil, workingDirectory: String? = nil, - changes: ChangesSummary? = nil + changes: ChangesSummary? = nil, + annotations: AnnotationsSummary? = nil ) { self.resource = resource self.provider = provider @@ -912,6 +920,7 @@ public struct SessionSummary: Codable, Sendable { self.agent = agent self.workingDirectory = workingDirectory self.changes = changes + self.annotations = annotations } } @@ -970,7 +979,7 @@ public struct Turn: Codable, Sendable { /// The message that initiated the turn public var message: Message /// All response content in stream order: text, tool calls, reasoning, and content refs. - /// + /// /// Consumers should derive display text by concatenating markdown parts, /// and find tool calls by filtering for `ToolCall` parts. public var responseParts: [ResponsePart] @@ -1004,7 +1013,7 @@ public struct ActiveTurn: Codable, Sendable { /// The message that initiated the turn public var message: Message /// All response content in stream order: text, tool calls, reasoning, and content refs. - /// + /// /// Tool call parts include `pendingPermissions` when permissions are awaiting user approval. public var responseParts: [ResponsePart] /// Token usage info @@ -1031,7 +1040,7 @@ public struct Message: Codable, Sendable { /// File/selection attachments public var attachments: [MessageAttachment]? /// Additional provider-specific metadata for this message. - /// + /// /// Clients MAY look for well-known keys here to provide enhanced UI, and /// agent hosts MAY use it to carry context that does not fit any other /// field. Mirrors the MCP `_meta` convention. @@ -1446,18 +1455,18 @@ public struct SimpleMessageAttachment: Codable, Sendable { public var range: TextRange? /// Advisory display hint for clients rendering this attachment. Recognized /// values include: - /// + /// /// - `'image'`: the attachment is an image /// - `'document'`: the attachment is a textual document /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) /// - `'directory'`: the attachment is a folder /// - `'selection'`: the attachment is a selection within a document - /// + /// /// Implementations MAY provide additional values; clients SHOULD fall back /// to a reasonable default when an unknown value is encountered. public var displayKind: String? /// Additional implementation-defined metadata for the attachment. - /// + /// /// If the attachment was produced by the `completions` command, the client /// MUST preserve every property of `_meta` originally returned by the agent /// host when sending the user message containing the accepted completion. @@ -1465,7 +1474,7 @@ public struct SimpleMessageAttachment: Codable, Sendable { /// Discriminant public var type: MessageAttachmentKind /// Representation of the attachment as it should be shown to the model. - /// + /// /// If the attachment was produced by the client, this property MUST be /// defined so the agent host can correctly interpret the attachment. This /// property MAY be omitted when the attachment originated from a @@ -1507,18 +1516,18 @@ public struct MessageEmbeddedResourceAttachment: Codable, Sendable { public var range: TextRange? /// Advisory display hint for clients rendering this attachment. Recognized /// values include: - /// + /// /// - `'image'`: the attachment is an image /// - `'document'`: the attachment is a textual document /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) /// - `'directory'`: the attachment is a folder /// - `'selection'`: the attachment is a selection within a document - /// + /// /// Implementations MAY provide additional values; clients SHOULD fall back /// to a reasonable default when an unknown value is encountered. public var displayKind: String? /// Additional implementation-defined metadata for the attachment. - /// + /// /// If the attachment was produced by the `completions` command, the client /// MUST preserve every property of `_meta` originally returned by the agent /// host when sending the user message containing the accepted completion. @@ -1530,7 +1539,7 @@ public struct MessageEmbeddedResourceAttachment: Codable, Sendable { /// Content MIME type (e.g. `"image/png"`, `"application/pdf"`) public var contentType: String /// Optional selection within the attached textual resource. - /// + /// /// Only meaningful for textual resources. public var selection: TextSelection? @@ -1575,18 +1584,18 @@ public struct MessageResourceAttachment: Codable, Sendable { public var range: TextRange? /// Advisory display hint for clients rendering this attachment. Recognized /// values include: - /// + /// /// - `'image'`: the attachment is an image /// - `'document'`: the attachment is a textual document /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) /// - `'directory'`: the attachment is a folder /// - `'selection'`: the attachment is a selection within a document - /// + /// /// Implementations MAY provide additional values; clients SHOULD fall back /// to a reasonable default when an unknown value is encountered. public var displayKind: String? /// Additional implementation-defined metadata for the attachment. - /// + /// /// If the attachment was produced by the `completions` command, the client /// MUST preserve every property of `_meta` originally returned by the agent /// host when sending the user message containing the accepted completion. @@ -1600,7 +1609,7 @@ public struct MessageResourceAttachment: Codable, Sendable { /// Discriminant public var type: MessageAttachmentKind /// Optional selection within the referenced textual resource. - /// + /// /// Only meaningful for textual resources. public var selection: TextSelection? @@ -1639,6 +1648,69 @@ public struct MessageResourceAttachment: Codable, Sendable { } } +public struct MessageAnnotationsAttachment: Codable, Sendable { + /// A human-readable label for the attachment (e.g. the filename of a file + /// attachment). Used for display in UI. + public var label: String + /// If defined, the range in {@link Message.text} that references this + /// attachment. This is a text range, not a byte range. + public var range: TextRange? + /// Advisory display hint for clients rendering this attachment. Recognized + /// values include: + /// + /// - `'image'`: the attachment is an image + /// - `'document'`: the attachment is a textual document + /// - `'symbol'`: the attachment is a code symbol (e.g. a function or class) + /// - `'directory'`: the attachment is a folder + /// - `'selection'`: the attachment is a selection within a document + /// + /// Implementations MAY provide additional values; clients SHOULD fall back + /// to a reasonable default when an unknown value is encountered. + public var displayKind: String? + /// Additional implementation-defined metadata for the attachment. + /// + /// If the attachment was produced by the `completions` command, the client + /// MUST preserve every property of `_meta` originally returned by the agent + /// host when sending the user message containing the accepted completion. + public var meta: [String: AnyCodable]? + /// Discriminant + public var type: MessageAttachmentKind + /// The annotations channel URI (typically `ahp-session://annotations`). + /// Matches {@link AnnotationsSummary.resource}. + public var resource: String + /// Specific {@link Annotation.id | annotation ids} to reference. When + /// omitted, the attachment references all annotations on the channel. + public var annotationIds: [String]? + + enum CodingKeys: String, CodingKey { + case label + case range + case displayKind + case meta = "_meta" + case type + case resource + case annotationIds + } + + public init( + label: String, + range: TextRange? = nil, + displayKind: String? = nil, + meta: [String: AnyCodable]? = nil, + type: MessageAttachmentKind, + resource: String, + annotationIds: [String]? = nil + ) { + self.label = label + self.range = range + self.displayKind = displayKind + self.meta = meta + self.type = type + self.resource = resource + self.annotationIds = annotationIds + } +} + public struct MarkdownResponsePart: Codable, Sendable { /// Discriminant public var kind: ResponsePartKind @@ -1755,11 +1827,11 @@ public struct ToolCallResult: Codable, Sendable { /// Past-tense description of what the tool did public var pastTenseMessage: StringOrMarkdown /// Unstructured result content blocks. - /// + /// /// This mirrors the `content` field of MCP `CallToolResult`. public var content: [ToolResultContent]? /// Optional structured result object. - /// + /// /// This mirrors the `structuredContent` field of MCP `CallToolResult`. public var structuredContent: [String: AnyCodable]? /// Error details if the tool failed @@ -1790,7 +1862,7 @@ public struct ToolCallStreamingState: Codable, Sendable { /// Reference to the contributor of the tool being called. public var contributor: ToolCallContributor? /// Additional provider-specific metadata for this tool call. - /// + /// /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination /// with the {@link contributor} to serve MCP Apps. @@ -1843,7 +1915,7 @@ public struct ToolCallPendingConfirmationState: Codable, Sendable { /// Reference to the contributor of the tool being called. public var contributor: ToolCallContributor? /// Additional provider-specific metadata for this tool call. - /// + /// /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination /// with the {@link contributor} to serve MCP Apps. @@ -1919,7 +1991,7 @@ public struct ToolCallRunningState: Codable, Sendable { /// Reference to the contributor of the tool being called. public var contributor: ToolCallContributor? /// Additional provider-specific metadata for this tool call. - /// + /// /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination /// with the {@link contributor} to serve MCP Apps. @@ -1934,7 +2006,7 @@ public struct ToolCallRunningState: Codable, Sendable { /// The confirmation option the user selected, if confirmation options were provided public var selectedOption: ConfirmationOption? /// Partial content produced while the tool is still executing. - /// + /// /// For example, a terminal content block lets clients subscribe to live /// output before the tool completes. public var content: [ToolResultContent]? @@ -1990,7 +2062,7 @@ public struct ToolCallPendingResultConfirmationState: Codable, Sendable { /// Reference to the contributor of the tool being called. public var contributor: ToolCallContributor? /// Additional provider-specific metadata for this tool call. - /// + /// /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination /// with the {@link contributor} to serve MCP Apps. @@ -2004,11 +2076,11 @@ public struct ToolCallPendingResultConfirmationState: Codable, Sendable { /// Past-tense description of what the tool did public var pastTenseMessage: StringOrMarkdown /// Unstructured result content blocks. - /// + /// /// This mirrors the `content` field of MCP `CallToolResult`. public var content: [ToolResultContent]? /// Optional structured result object. - /// + /// /// This mirrors the `structuredContent` field of MCP `CallToolResult`. public var structuredContent: [String: AnyCodable]? /// Error details if the tool failed @@ -2082,7 +2154,7 @@ public struct ToolCallCompletedState: Codable, Sendable { /// Reference to the contributor of the tool being called. public var contributor: ToolCallContributor? /// Additional provider-specific metadata for this tool call. - /// + /// /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination /// with the {@link contributor} to serve MCP Apps. @@ -2096,11 +2168,11 @@ public struct ToolCallCompletedState: Codable, Sendable { /// Past-tense description of what the tool did public var pastTenseMessage: StringOrMarkdown /// Unstructured result content blocks. - /// + /// /// This mirrors the `content` field of MCP `CallToolResult`. public var content: [ToolResultContent]? /// Optional structured result object. - /// + /// /// This mirrors the `structuredContent` field of MCP `CallToolResult`. public var structuredContent: [String: AnyCodable]? /// Error details if the tool failed @@ -2174,7 +2246,7 @@ public struct ToolCallCancelledState: Codable, Sendable { /// Reference to the contributor of the tool being called. public var contributor: ToolCallContributor? /// Additional provider-specific metadata for this tool call. - /// + /// /// This MAY include a `ui` field corresponding to the MCP Apps (SEP-1865) /// `McpUiToolMeta` found in MCP tool calls, which may be used in combination /// with the {@link contributor} to serve MCP Apps. @@ -2245,7 +2317,7 @@ public struct ConfirmationOption: Codable, Sendable { /// Whether this option represents an approval or denial public var kind: ConfirmationOptionKind /// Logical group number for visual categorisation. - /// + /// /// Clients SHOULD display options in the order they are defined and MAY /// use differing group numbers to insert dividers between logical clusters /// of options. @@ -2272,18 +2344,18 @@ public struct ToolDefinition: Codable, Sendable { /// Description of what the tool does public var description: String? /// JSON Schema defining the expected input parameters. - /// + /// /// Optional because client-provided tools may not have formal schemas. /// Mirrors MCP `Tool.inputSchema`. public var inputSchema: AnyCodable? /// JSON Schema defining the structure of the tool's output. - /// + /// /// Mirrors MCP `Tool.outputSchema`. public var outputSchema: AnyCodable? /// Behavioral hints about the tool. All properties are advisory. public var annotations: ToolAnnotations? /// Additional provider-specific metadata. - /// + /// /// Mirrors the MCP `_meta` convention. public var meta: [String: AnyCodable]? @@ -2518,7 +2590,7 @@ public struct PluginCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -2542,7 +2614,7 @@ public struct PluginCustomization: Codable, Sendable { /// a load state for this container. public var load: CustomizationLoadState? /// Children discovered inside this container. - /// + /// /// Absent means the host has not parsed this container yet. An empty /// array means the host parsed the container and it contributes /// nothing. @@ -2581,7 +2653,7 @@ public struct ClientPluginCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -2605,7 +2677,7 @@ public struct ClientPluginCustomization: Codable, Sendable { /// a load state for this container. public var load: CustomizationLoadState? /// Children discovered inside this container. - /// + /// /// Absent means the host has not parsed this container yet. An empty /// array means the host parsed the container and it contributes /// nothing. @@ -2648,7 +2720,7 @@ public struct DirectoryCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -2672,7 +2744,7 @@ public struct DirectoryCustomization: Codable, Sendable { /// a load state for this container. public var load: CustomizationLoadState? /// Children discovered inside this container. - /// + /// /// Absent means the host has not parsed this container yet. An empty /// array means the host parsed the container and it contributes /// nothing. @@ -2719,7 +2791,7 @@ public struct AgentCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -2739,7 +2811,7 @@ public struct AgentCustomization: Codable, Sendable { /// invoke it. Sourced from the agent file's frontmatter `description`. public var description: String? /// Additional provider-specific metadata for this custom agent. - /// + /// /// Mirrors the MCP `_meta` convention. public var meta: [String: AnyCodable]? @@ -2782,7 +2854,7 @@ public struct SkillCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -2834,7 +2906,7 @@ public struct PromptCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -2879,7 +2951,7 @@ public struct RuleCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -2935,7 +3007,7 @@ public struct HookCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -2976,7 +3048,7 @@ public struct McpServerCustomization: Codable, Sendable { public var id: String /// Source URI for this customization. A plugin URL, a file URI, or a /// directory URI. - /// + /// /// For declarations that live inside a larger file — e.g. an MCP /// server declared inline in a `plugins.json` manifest — `uri` points /// to the containing file and {@link CustomizationBase.range | `range`} @@ -3000,12 +3072,12 @@ public struct McpServerCustomization: Codable, Sendable { /// into the upstream MCP server itself. The channel is NOT a fresh raw MCP /// connection: it piggybacks on the AHP transport /// and skips the MCP `initialize` sequence. - /// + /// /// The agent host MAY only serve a subset of MCP on this /// channel; the served subset is described by domain-specific /// capabilities such as those in /// {@link McpServerCustomizationApps.capabilities}. - /// + /// /// The channel URI SHOULD be stable across the server's lifetime, but /// the agent host MAY change it (for example across a restart) and /// MAY only expose it while the server is in @@ -3160,7 +3232,7 @@ public struct ToolCallClientContributor: Codable, Sendable { public var kind: ToolCallContributorKind /// If this tool is provided by a client, the `clientId` of the owning client. /// Absent for server-side tools. - /// + /// /// When set, the identified client is responsible for executing the tool and /// dispatching `session/toolCallComplete` with the result. public var clientId: String @@ -3278,10 +3350,10 @@ public struct TerminalState: Codable, Sendable { /// Terminal height in rows public var rows: Int? /// Typed content parts, replacing the flat `content: string`. - /// + /// /// Naive consumers that only need the raw VT stream can reconstruct it with: /// `content.map(p => p.type === 'command' ? p.output : p.value).join('')` - /// + /// /// Consumers that need command boundaries can filter by part type. public var content: [TerminalContentPart] /// Process exit code, set when the terminal process exits @@ -3290,7 +3362,7 @@ public struct TerminalState: Codable, Sendable { public var claim: TerminalClaim /// Whether this terminal emits `terminal/commandExecuted` and /// `terminal/commandFinished` actions and populates `command`-typed parts. - /// + /// /// Clients MUST check this flag before relying on command detection. /// Do NOT use the presence of a `command` part as a feature flag — parts /// are absent in the normal idle state. @@ -3451,17 +3523,17 @@ public struct Changeset: Codable, Sendable { /// RFC 6570 URI template. Clients parse the variables directly out of the /// template using the standard `{name}` syntax — they are not redeclared /// here. - /// + /// /// Only the following template shapes are defined by this protocol; any /// other variable name MUST be ignored by clients (there is no /// protocol-defined way to obtain values for unknown variables): - /// + /// /// | Variables in template | Meaning | /// | ------------------------------------------- | ------------------------------------------------------------------------------------ | /// | _(none)_ | A static, session-wide changeset. The template is itself a subscribable URI. | /// | `{turnId}` | Per-turn slice. Expand with a `Turn.id` from the session. | /// | `{originalTurnId}` and `{modifiedTurnId}` | Diff between two turns. Both variables MUST be present. | - /// + /// /// Future protocol versions MAY add new well-known variables. public var uriTemplate: String /// Optional longer description. @@ -3469,7 +3541,7 @@ public struct Changeset: Codable, Sendable { /// Advisory hint describing what kind of changeset this is, so clients can /// group, sort, or render an appropriate icon without parsing /// {@link uriTemplate}. Recognized values include: - /// + /// /// - `'session'`: a static, session-wide changeset covering all changes the /// agent has produced in this session. /// - `'branch'`: changes relative to a base branch (e.g. a feature branch @@ -3480,7 +3552,7 @@ public struct Changeset: Codable, Sendable { /// - `'compare-turns'`: a diff between two turns. Typically paired with /// `{originalTurnId}` and `{modifiedTurnId}` variables in /// {@link uriTemplate}. - /// + /// /// Implementations MAY provide additional values; clients SHOULD fall back /// to a reasonable default when an unknown value is encountered. public var changeKind: String @@ -3573,7 +3645,7 @@ public struct ChangesetOperation: Codable, Sendable { /// is in flight, {@link ChangesetOperationStatus.Error | Error} when the /// most recent invocation failed, and /// {@link ChangesetOperationStatus.Idle | Idle} otherwise. - /// + /// /// Clients SHOULD reflect this state in the UI — e.g. disabling the /// control or showing a spinner while `Running`, and surfacing /// {@link error} while `Error`. @@ -3603,23 +3675,139 @@ public struct ChangesetOperation: Codable, Sendable { } } +public struct AnnotationsSummary: Codable, Sendable { + /// The subscribable annotations channel URI for the owning session + /// (typically `ahp-session://annotations`). Surfaced explicitly even + /// though it is derivable from the session URI so badge UI does not need + /// to know the derivation rule. + public var resource: String + /// Total number of {@link Annotation} entries in the channel. + public var annotationCount: Int + /// Total number of {@link AnnotationEntry} entries across every annotation. + public var entryCount: Int + + public init( + resource: String, + annotationCount: Int, + entryCount: Int + ) { + self.resource = resource + self.annotationCount = annotationCount + self.entryCount = entryCount + } +} + +public struct AnnotationsState: Codable, Sendable { + /// Annotations in this channel, keyed by {@link Annotation.id}. + public var annotations: [Annotation] + + public init( + annotations: [Annotation] + ) { + self.annotations = annotations + } +} + +public struct Annotation: Codable, Sendable { + /// Stable identifier within the annotations channel. Assigned by the client + /// that dispatches the creating {@link AnnotationsSetAction}. + public var id: String + /// Turn that produced the file versions this annotation is anchored to. + /// Matches a {@link Turn.id} on the owning session. + public var turnId: String + /// The file the annotation is anchored to. + public var resource: String + /// Range within {@link resource} the annotation is anchored to. When + /// omitted the annotation is anchored to the entire file. + public var range: TextRange? + /// Whether the annotation has been resolved. Newly created annotations are + /// always unresolved (`false`); a client marks an annotation resolved (or + /// re-opens it) by dispatching an {@link AnnotationsSetAction} carrying the + /// updated flag. + public var resolved: Bool + /// Entries in this annotation, in dispatch order (oldest first). MUST + /// contain at least one entry. + public var entries: [AnnotationEntry] + /// Producer-defined opaque metadata, surfaced to tooling but not + /// interpreted by the protocol. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case id + case turnId + case resource + case range + case resolved + case entries + case meta = "_meta" + } + + public init( + id: String, + turnId: String, + resource: String, + range: TextRange? = nil, + resolved: Bool, + entries: [AnnotationEntry], + meta: [String: AnyCodable]? = nil + ) { + self.id = id + self.turnId = turnId + self.resource = resource + self.range = range + self.resolved = resolved + self.entries = entries + self.meta = meta + } +} + +public struct AnnotationEntry: Codable, Sendable { + /// Stable identifier within the enclosing annotation. Assigned by the client + /// that dispatches the {@link AnnotationsEntrySetAction} (or the enclosing + /// {@link AnnotationsSetAction}) introducing the entry. + public var id: String + /// Entry body. A bare `string` is rendered as plain text; pass + /// `{ markdown: "…" }` to opt into Markdown rendering. See + /// {@link StringOrMarkdown}. + public var text: StringOrMarkdown + /// Producer-defined opaque metadata, surfaced to tooling but not + /// interpreted by the protocol. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case id + case text + case meta = "_meta" + } + + public init( + id: String, + text: StringOrMarkdown, + meta: [String: AnyCodable]? = nil + ) { + self.id = id + self.text = text + self.meta = meta + } +} + public struct TelemetryCapabilities: Codable, Sendable { /// Channel URI (or RFC 6570 URI template) for OTLP log records /// (`otlp/exportLogs` notifications). - /// + /// /// The following template variables are defined by this protocol; any /// other variable name MUST be ignored by clients (there is no /// protocol-defined way to obtain values for unknown variables): - /// + /// /// | Variables in template | Meaning | /// | --------------------- | ------------------------------------------------------------------------------------------------------- | /// | _(none)_ | The host does not support subscriber-side severity filtering. The template is itself a subscribable URI. | /// | `{level}` | Minimum OTLP severity to deliver. Expand to one of the [OTLP `SeverityNumber`](https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber) short names (case-insensitive): `trace`, `debug`, `info`, `warn`, `error`, `fatal`. The server delivers log records whose `severityNumber` falls in the corresponding band or above. | - /// + /// /// Hosts SHOULD honour the expanded `{level}`; clients MUST still filter /// defensively in case a host ignores the parameter. Hosts that do not /// advertise `{level}` deliver all severities. - /// + /// /// Future protocol versions MAY add new well-known variables (e.g. scope /// or attribute filters). public var logs: String? @@ -3956,6 +4144,7 @@ public enum MessageAttachment: Codable, Sendable { case simple(SimpleMessageAttachment) case embeddedResource(MessageEmbeddedResourceAttachment) case resource(MessageResourceAttachment) + case annotations(MessageAnnotationsAttachment) private enum DiscriminantKey: String, CodingKey { case discriminant = "type" @@ -3971,6 +4160,8 @@ public enum MessageAttachment: Codable, Sendable { self = .embeddedResource(try MessageEmbeddedResourceAttachment(from: decoder)) case "resource": self = .resource(try MessageResourceAttachment(from: decoder)) + case "annotations": + self = .annotations(try MessageAnnotationsAttachment(from: decoder)) default: throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown MessageAttachment discriminant: \(discriminant)") } @@ -3981,6 +4172,7 @@ public enum MessageAttachment: Codable, Sendable { case .simple(let value): try value.encode(to: encoder) case .embeddedResource(let value): try value.encode(to: encoder) case .resource(let value): try value.encode(to: encoder) + case .annotations(let value): try value.encode(to: encoder) } } } @@ -4224,12 +4416,13 @@ public enum ToolResultContent: Codable, Sendable { } } -/// The state payload of a snapshot — root, session, terminal, or changeset state. +/// The state payload of a snapshot — root, session, terminal, changeset, or annotations state. public enum SnapshotState: Codable, Sendable { case root(RootState) case session(SessionState) case terminal(TerminalState) case changeset(ChangesetState) + case annotations(AnnotationsState) public init(from decoder: Decoder) throws { // SessionState has required `summary` field, try it first @@ -4239,6 +4432,8 @@ public enum SnapshotState: Codable, Sendable { self = .terminal(terminal) } else if let changeset = try? ChangesetState(from: decoder) { self = .changeset(changeset) + } else if let annotations = try? AnnotationsState(from: decoder) { + self = .annotations(annotations) } else { self = .root(try RootState(from: decoder)) } @@ -4250,6 +4445,7 @@ public enum SnapshotState: Codable, Sendable { case .session(let state): try state.encode(to: encoder) case .terminal(let state): try state.encode(to: encoder) case .changeset(let state): try state.encode(to: encoder) + case .annotations(let state): try state.encode(to: encoder) } } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift index 7b263f65..7f6eed7c 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift @@ -15,6 +15,7 @@ public actor AHPStateMirror { public private(set) var sessions: [String: SessionState] = [:] public private(set) var terminals: [String: TerminalState] = [:] public private(set) var changesets: [String: ChangesetState] = [:] + public private(set) var annotations: [String: AnnotationsState] = [:] public init() {} @@ -48,10 +49,16 @@ public actor AHPStateMirror { // mutated only when fresh snapshots arrive. return } + if annotations[channel] != nil { + // Annotations are also seeded by `applySnapshot` and currently + // mutated only when fresh snapshots arrive. + return + } } - /// Seed the mirror from a `Snapshot` — root, session, or terminal as - /// the snapshot's `state` discriminator dictates. + /// Seed the mirror from a `Snapshot` — root, session, terminal, + /// changeset, or annotations as the snapshot's `state` discriminator + /// dictates. public func applySnapshot(_ snapshot: Snapshot) { switch snapshot.state { case .root(let state): @@ -62,6 +69,8 @@ public actor AHPStateMirror { terminals[snapshot.resource] = state case .changeset(let state): changesets[snapshot.resource] = state + case .annotations(let state): + annotations[snapshot.resource] = state } } @@ -71,5 +80,6 @@ public actor AHPStateMirror { sessions.removeAll() terminals.removeAll() changesets.removeAll() + annotations.removeAll() } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift index 1521fa86..c6518405 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift @@ -47,6 +47,7 @@ public actor MultiHostStateMirror { public private(set) var sessions: [HostedResourceKey: SessionState] = [:] public private(set) var terminals: [HostedResourceKey: TerminalState] = [:] public private(set) var changesets: [HostedResourceKey: ChangesetState] = [:] + public private(set) var annotations: [HostedResourceKey: AnnotationsState] = [:] public init() {} @@ -87,13 +88,18 @@ public actor MultiHostStateMirror { // mutated only when fresh snapshots arrive. return } + if annotations[key] != nil { + // Annotations are also seeded by `applySnapshot` and currently + // mutated only when fresh snapshots arrive. + return + } // No state for this `(host, channel)` yet — the reducer can't // initialise one; only `applySnapshot(host:snapshot:)` can. } /// Seed the mirror from a `Snapshot` scoped to `host` — root, - /// session, terminal, or changeset as the snapshot's `state` - /// discriminator dictates. + /// session, terminal, changeset, or annotations as the snapshot's + /// `state` discriminator dictates. public func applySnapshot(host: HostId, snapshot: Snapshot) { let key = HostedResourceKey(hostId: host, uri: snapshot.resource) switch snapshot.state { @@ -105,17 +111,21 @@ public actor MultiHostStateMirror { terminals[key] = state case .changeset(let state): changesets[key] = state + case .annotations(let state): + annotations[key] = state } } /// Reset every slot for `host` — drops the root state, all sessions - /// keyed under that host, all terminals keyed under that host, and - /// all changesets keyed under that host. + /// keyed under that host, all terminals keyed under that host, all + /// changesets keyed under that host, and all annotations keyed under + /// that host. public func reset(host: HostId) { rootStates.removeValue(forKey: host) sessions = sessions.filter { $0.key.hostId != host } terminals = terminals.filter { $0.key.hostId != host } changesets = changesets.filter { $0.key.hostId != host } + annotations = annotations.filter { $0.key.hostId != host } } /// Reset every host's state. @@ -124,5 +134,6 @@ public actor MultiHostStateMirror { sessions.removeAll() terminals.removeAll() changesets.removeAll() + annotations.removeAll() } } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift index be111921..98432e9c 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift @@ -89,9 +89,9 @@ final class FixtureDrivenReducerTests: XCTestCase { var skipped: [(file: String, description: String, message: String)] = [] for (file, fixture) in Self.fixtures { - // Skip terminal/changeset/resourceWatch fixtures — those reducers - // are not yet implemented in Swift - if fixture.reducer == "terminal" || fixture.reducer == "changeset" || fixture.reducer == "resourceWatch" { + // Skip terminal/changeset/annotations/resourceWatch fixtures — + // those reducers are not yet implemented in Swift + if fixture.reducer == "terminal" || fixture.reducer == "changeset" || fixture.reducer == "annotations" || fixture.reducer == "resourceWatch" { continue } diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index 86782107..5d680c6b 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -52,6 +52,18 @@ Implements AHP 0.3.0. `idle → running → error` lifecycle of a changeset operation. - `AgentCustomization._meta` provider metadata field. - Optional `changes` field on `SessionSummary` (`ChangesSummary` with optional `additions`, `deletions`, and `files` counts) summarising a session's file-change footprint. +- New annotations channel wire types (`ahp-session://annotations`): + `AnnotationsState`, `Annotation`, `AnnotationEntry`, + `AnnotationsSummary`; and the client-dispatchable + `annotations/set` / `annotations/removed` / `annotations/entrySet` + / `annotations/entryRemoved` cases on `StateAction` — clients drive every + annotation mutation by dispatching these directly, assigning the + `Annotation.id` / `AnnotationEntry.id` themselves; and + `SnapshotState.annotations`. + Reducer logic is deferred (matches the changeset/resource-watch parity). +- `MessageAnnotationsAttachment` (`annotations` `MessageAttachment` variant) + referencing annotations on a session's annotations channel by `resource` + URI, optionally narrowed to an `annotationIds` array. ### Changed diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 29024103..aec639e5 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -55,6 +55,17 @@ Implements AHP 0.3.0. `idle → running → error` lifecycle of a changeset operation. - `AgentCustomization._meta` provider metadata field. - Optional `changes` field on `SessionSummary` (`ChangesSummary` with optional `additions`, `deletions`, and `files` counts) summarising a session's file-change footprint. +- New annotations channel (`ahp-session://annotations`): `AnnotationsState`, + `Annotation`, `AnnotationEntry`, `AnnotationsSummary`, + the `annotationsReducer`, and the client-dispatchable `annotations/set`, + `annotations/removed`, `annotations/entrySet`, and `annotations/entryRemoved` + actions — clients drive every annotation mutation by dispatching these + directly, assigning the `Annotation.id` / `AnnotationEntry.id` themselves. + `SessionSummary.annotations` surfaces the per-session `AnnotationsSummary` + for badge UI. +- `MessageAnnotationsAttachment` (`annotations` `MessageAttachment` variant) + referencing annotations on a session's annotations channel by `resource` + URI, optionally narrowed to an `annotationIds` array. ### Changed diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d1097a0a..6b988f5b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -89,6 +89,7 @@ export default withMermaid(defineConfig({ { text: 'Session Channel', link: '/reference/session' }, { text: 'Terminal Channel', link: '/reference/terminal' }, { text: 'Changeset Channel', link: '/reference/changeset' }, + { text: 'Annotations Channel', link: '/reference/annotations' }, { text: 'Telemetry Channel', link: '/reference/otlp' }, ], }, diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 9a715e72..6aa2a0f6 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -149,6 +149,19 @@ Terminal actions travel on the relevant [Terminal Channel](/specification/termin See the [Terminals guide](/guide/terminals) for usage flows. +## Annotations Actions + +Annotations actions travel on a session's annotations channel (`ahp-session://annotations`). Every annotations action is client-dispatchable — clients create, re-anchor, resolve, and delete annotations and their entries by dispatching these directly (assigning the `Annotation.id` / `AnnotationEntry.id` themselves and applying them optimistically), and the agent host MAY also originate them. + +| Type | Client-dispatchable? | When | +|---|---|---| +| `annotations/set` | **Yes** | Upsert an annotation — create one with its mandatory first entry, or re-anchor / resolve an existing one | +| `annotations/removed` | **Yes** | Remove an entire annotation (and every entry it contains) | +| `annotations/entrySet` | **Yes** | Upsert a single entry within an annotation (add or edit) | +| `annotations/entryRemoved` | **Yes** | Remove a single entry; dispatch `annotations/removed` instead to drop the last remaining entry | + +See the [Annotations Channel reference](/reference/annotations) for the full state shape. + ## Client-Dispatched Actions Clients interact with the server by dispatching actions as fire-and-forget notifications: diff --git a/docs/guide/comments.md b/docs/guide/comments.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/specification/comments-channel.md b/docs/specification/comments-channel.md new file mode 100644 index 00000000..e69de29b diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 65877bf9..a151b7b8 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1500,6 +1500,84 @@ "type" ] }, + "AnnotationsSetAction": { + "type": "object", + "description": "Upsert an {@link Annotation} in the annotations channel — adds a new\nannotation, or replaces an existing one identified by\n{@link Annotation.id}.\n\nDispatched by a client to create an annotation (together with its\nmandatory first entry) or to re-anchor / resolve an existing one; the\ndispatching client assigns the {@link Annotation.id} and the id of any\nnew entry. When replacing, the full annotation payload (including its\n{@link Annotation.entries | entries} list) is substituted; producers\nSHOULD prefer {@link AnnotationsEntrySetAction} for per-entry edits to\nkeep wire updates small.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.AnnotationsSet" + }, + "annotation": { + "$ref": "#/$defs/Annotation", + "description": "The new or replacement annotation. MUST contain at least one entry." + } + }, + "required": [ + "type", + "annotation" + ] + }, + "AnnotationsRemovedAction": { + "type": "object", + "description": "Remove an {@link Annotation} from the channel by its id.\n\nDispatched to delete an entire annotation and every entry it contains.\nBecause the protocol forbids empty annotations, a client that wants to\nremove the last remaining entry dispatches this action — collapsing the\nannotation — rather than {@link AnnotationsEntryRemovedAction}.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.AnnotationsRemoved" + }, + "annotationId": { + "type": "string", + "description": "The {@link Annotation.id} of the annotation to remove." + } + }, + "required": [ + "type", + "annotationId" + ] + }, + "AnnotationsEntrySetAction": { + "type": "object", + "description": "Upsert an {@link AnnotationEntry} within an existing annotation — adds a\nnew entry, or replaces one identified by {@link AnnotationEntry.id}. The\ndispatching client assigns the {@link AnnotationEntry.id} of a new entry.\nIf {@link annotationId} does not match any current annotation the action\nis a no-op.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.AnnotationsEntrySet" + }, + "annotationId": { + "type": "string", + "description": "The {@link Annotation.id} the entry belongs to." + }, + "entry": { + "$ref": "#/$defs/AnnotationEntry", + "description": "The new or replacement entry." + } + }, + "required": [ + "type", + "annotationId", + "entry" + ] + }, + "AnnotationsEntryRemovedAction": { + "type": "object", + "description": "Remove a single {@link AnnotationEntry} from an annotation without\ncollapsing the annotation itself. Used when more than one entry remains —\nto remove the last entry a client dispatches {@link AnnotationsRemovedAction}\ninstead, since the protocol forbids empty annotations.\n\nIf either {@link annotationId} or {@link entryId} does not match the\ncurrent state the action is a no-op.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.AnnotationsEntryRemoved" + }, + "annotationId": { + "type": "string", + "description": "The {@link Annotation.id} the entry belongs to." + }, + "entryId": { + "type": "string", + "description": "The {@link AnnotationEntry.id} to remove." + } + }, + "required": [ + "type", + "annotationId", + "entryId" + ] + }, "ResourceWatchChangedAction": { "type": "object", "description": "A batch of resource changes observed by the watcher.\n\nWatch events are coalesced into batches by the server to keep the\naction stream tractable; an empty `changes.items` list MUST NOT be\ndispatched. The reducer does not retain change history — these\nactions exist purely to deliver events to subscribers, who consume\nthem directly off the action stream and apply their own logic.", @@ -1942,6 +2020,9 @@ }, { "$ref": "#/$defs/ChangesetState" + }, + { + "$ref": "#/$defs/AnnotationsState" } ], "description": "The current state of the resource" @@ -2323,6 +2404,10 @@ "changes": { "$ref": "#/$defs/ChangesSummary", "description": "Aggregate summary of file changes associated with this session. Servers\nmay populate this to give clients a quick at-a-glance view of the\nsession's footprint (e.g., for list rendering) without requiring the\nclient to subscribe to a changeset." + }, + "annotations": { + "$ref": "#/$defs/AnnotationsSummary", + "description": "Lightweight summary of this session's inline annotations channel\n(`ahp-session://annotations`). Surfaced so badge UI can render\nannotation / entry counts without subscribing. Absent when the session\ndoes not expose an annotations channel." } }, "required": [ @@ -3204,6 +3289,49 @@ "type" ] }, + "MessageAnnotationsAttachment": { + "type": "object", + "description": "An attachment that references annotations on a session's annotations\nchannel (see {@link AnnotationsState}).\n\nWhen {@link annotationIds} is omitted the attachment references every\nannotation on the channel; when present it references only the listed\n{@link Annotation.id | annotation ids}.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "$ref": "#/$defs/MessageAttachmentKind.Annotations", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The annotations channel URI (typically `ahp-session://annotations`).\nMatches {@link AnnotationsSummary.resource}." + }, + "annotationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific {@link Annotation.id | annotation ids} to reference. When\nomitted, the attachment references all annotations on the channel." + } + }, + "required": [ + "label", + "type", + "resource" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -5216,6 +5344,113 @@ "status" ] }, + "AnnotationsSummary": { + "type": "object", + "description": "Lightweight per-session summary of the annotations channel, surfaced on\n{@link SessionSummary.annotations} so badge UI can render annotation /\nentry counts without subscribing to the channel itself.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "The subscribable annotations channel URI for the owning session\n(typically `ahp-session://annotations`). Surfaced explicitly even\nthough it is derivable from the session URI so badge UI does not need\nto know the derivation rule." + }, + "annotationCount": { + "type": "number", + "description": "Total number of {@link Annotation} entries in the channel." + }, + "entryCount": { + "type": "number", + "description": "Total number of {@link AnnotationEntry} entries across every annotation." + } + }, + "required": [ + "resource", + "annotationCount", + "entryCount" + ] + }, + "AnnotationsState": { + "type": "object", + "description": "Full state for a session's annotations channel, returned when a client\nsubscribes to an `ahp-session://annotations` URI.", + "properties": { + "annotations": { + "type": "array", + "items": { + "$ref": "#/$defs/Annotation" + }, + "description": "Annotations in this channel, keyed by {@link Annotation.id}." + } + }, + "required": [ + "annotations" + ] + }, + "Annotation": { + "type": "object", + "description": "A conversation anchored to a specific file produced by a specific turn,\noptionally narrowed to a range within that file.\n\n{@link turnId} anchors the annotation to the file versions that turn\nproduced, so a later turn that rewrites the same file does not silently\ninvalidate the annotation's anchor — clients can resolve {@link resource}\nand {@link range} against the turn's changeset. When {@link range} is\nomitted the annotation is anchored to the entire file.\n\nEvery annotation MUST contain at least one {@link AnnotationEntry}. An\n{@link AnnotationsSetAction} that creates an annotation therefore carries\nits mandatory first entry, and removing the last remaining entry collapses\nthe annotation via {@link AnnotationsRemovedAction} rather than leaving an\nempty annotation behind.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the annotations channel. Assigned by the client\nthat dispatches the creating {@link AnnotationsSetAction}." + }, + "turnId": { + "type": "string", + "description": "Turn that produced the file versions this annotation is anchored to.\nMatches a {@link Turn.id} on the owning session." + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The file the annotation is anchored to." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within {@link resource} the annotation is anchored to. When\nomitted the annotation is anchored to the entire file." + }, + "resolved": { + "type": "boolean", + "description": "Whether the annotation has been resolved. Newly created annotations are\nalways unresolved (`false`); a client marks an annotation resolved (or\nre-opens it) by dispatching an {@link AnnotationsSetAction} carrying the\nupdated flag." + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/AnnotationEntry" + }, + "description": "Entries in this annotation, in dispatch order (oldest first). MUST\ncontain at least one entry." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "turnId", + "resource", + "resolved", + "entries" + ] + }, + "AnnotationEntry": { + "type": "object", + "description": "A single entry within an {@link Annotation}.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the enclosing annotation. Assigned by the client\nthat dispatches the {@link AnnotationsEntrySetAction} (or the enclosing\n{@link AnnotationsSetAction}) introducing the entry." + }, + "text": { + "$ref": "#/$defs/StringOrMarkdown", + "description": "Entry body. A bare `string` is rendered as plain text; pass\n`{ markdown: \"…\" }` to opt into Markdown rendering. See\n{@link StringOrMarkdown}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "text" + ] + }, "TelemetryCapabilities": { "type": "object", "description": "OTLP telemetry channels the agent host emits.\n\nEach field, when present, is either a literal channel URI or an\n[RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI template\na client expands and then subscribes to. Absent fields indicate the host\ndoes not emit that signal.\n\nChannel URIs use the `ahp-otlp:` scheme. The scheme identifies the\nprotocol (OpenTelemetry over AHP) so clients can recognise the channel\ntype by URI alone; the host is free to choose any authority/path that\nmakes sense for its implementation. Clients MUST treat the URI as\nopaque (apart from expanding any well-known template variables defined\nbelow) and subscribe with the resulting concrete URI.\n\nPayloads delivered on these channels are OTLP/JSON values — see\n[opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto)\nfor the wire shapes (`ExportLogsServiceRequest`,\n`ExportTraceServiceRequest`, `ExportMetricsServiceRequest`).", @@ -5374,6 +5609,9 @@ }, { "$ref": "#/$defs/MessageResourceAttachment" + }, + { + "$ref": "#/$defs/MessageAnnotationsAttachment" } ], "description": "An attachment associated with a {@link Message}." @@ -5740,6 +5978,18 @@ { "$ref": "#/$defs/ChangesetClearedAction" }, + { + "$ref": "#/$defs/AnnotationsSetAction" + }, + { + "$ref": "#/$defs/AnnotationsRemovedAction" + }, + { + "$ref": "#/$defs/AnnotationsEntrySetAction" + }, + { + "$ref": "#/$defs/AnnotationsEntryRemovedAction" + }, { "$ref": "#/$defs/TerminalDataAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 55ae1d1e..8070e89f 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1582,6 +1582,9 @@ }, { "$ref": "#/$defs/ChangesetState" + }, + { + "$ref": "#/$defs/AnnotationsState" } ], "description": "The current state of the resource" @@ -1963,6 +1966,10 @@ "changes": { "$ref": "#/$defs/ChangesSummary", "description": "Aggregate summary of file changes associated with this session. Servers\nmay populate this to give clients a quick at-a-glance view of the\nsession's footprint (e.g., for list rendering) without requiring the\nclient to subscribe to a changeset." + }, + "annotations": { + "$ref": "#/$defs/AnnotationsSummary", + "description": "Lightweight summary of this session's inline annotations channel\n(`ahp-session://annotations`). Surfaced so badge UI can render\nannotation / entry counts without subscribing. Absent when the session\ndoes not expose an annotations channel." } }, "required": [ @@ -2844,6 +2851,49 @@ "type" ] }, + "MessageAnnotationsAttachment": { + "type": "object", + "description": "An attachment that references annotations on a session's annotations\nchannel (see {@link AnnotationsState}).\n\nWhen {@link annotationIds} is omitted the attachment references every\nannotation on the channel; when present it references only the listed\n{@link Annotation.id | annotation ids}.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "$ref": "#/$defs/MessageAttachmentKind.Annotations", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The annotations channel URI (typically `ahp-session://annotations`).\nMatches {@link AnnotationsSummary.resource}." + }, + "annotationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific {@link Annotation.id | annotation ids} to reference. When\nomitted, the attachment references all annotations on the channel." + } + }, + "required": [ + "label", + "type", + "resource" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -4856,6 +4906,113 @@ "status" ] }, + "AnnotationsSummary": { + "type": "object", + "description": "Lightweight per-session summary of the annotations channel, surfaced on\n{@link SessionSummary.annotations} so badge UI can render annotation /\nentry counts without subscribing to the channel itself.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "The subscribable annotations channel URI for the owning session\n(typically `ahp-session://annotations`). Surfaced explicitly even\nthough it is derivable from the session URI so badge UI does not need\nto know the derivation rule." + }, + "annotationCount": { + "type": "number", + "description": "Total number of {@link Annotation} entries in the channel." + }, + "entryCount": { + "type": "number", + "description": "Total number of {@link AnnotationEntry} entries across every annotation." + } + }, + "required": [ + "resource", + "annotationCount", + "entryCount" + ] + }, + "AnnotationsState": { + "type": "object", + "description": "Full state for a session's annotations channel, returned when a client\nsubscribes to an `ahp-session://annotations` URI.", + "properties": { + "annotations": { + "type": "array", + "items": { + "$ref": "#/$defs/Annotation" + }, + "description": "Annotations in this channel, keyed by {@link Annotation.id}." + } + }, + "required": [ + "annotations" + ] + }, + "Annotation": { + "type": "object", + "description": "A conversation anchored to a specific file produced by a specific turn,\noptionally narrowed to a range within that file.\n\n{@link turnId} anchors the annotation to the file versions that turn\nproduced, so a later turn that rewrites the same file does not silently\ninvalidate the annotation's anchor — clients can resolve {@link resource}\nand {@link range} against the turn's changeset. When {@link range} is\nomitted the annotation is anchored to the entire file.\n\nEvery annotation MUST contain at least one {@link AnnotationEntry}. An\n{@link AnnotationsSetAction} that creates an annotation therefore carries\nits mandatory first entry, and removing the last remaining entry collapses\nthe annotation via {@link AnnotationsRemovedAction} rather than leaving an\nempty annotation behind.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the annotations channel. Assigned by the client\nthat dispatches the creating {@link AnnotationsSetAction}." + }, + "turnId": { + "type": "string", + "description": "Turn that produced the file versions this annotation is anchored to.\nMatches a {@link Turn.id} on the owning session." + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The file the annotation is anchored to." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within {@link resource} the annotation is anchored to. When\nomitted the annotation is anchored to the entire file." + }, + "resolved": { + "type": "boolean", + "description": "Whether the annotation has been resolved. Newly created annotations are\nalways unresolved (`false`); a client marks an annotation resolved (or\nre-opens it) by dispatching an {@link AnnotationsSetAction} carrying the\nupdated flag." + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/AnnotationEntry" + }, + "description": "Entries in this annotation, in dispatch order (oldest first). MUST\ncontain at least one entry." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "turnId", + "resource", + "resolved", + "entries" + ] + }, + "AnnotationEntry": { + "type": "object", + "description": "A single entry within an {@link Annotation}.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the enclosing annotation. Assigned by the client\nthat dispatches the {@link AnnotationsEntrySetAction} (or the enclosing\n{@link AnnotationsSetAction}) introducing the entry." + }, + "text": { + "$ref": "#/$defs/StringOrMarkdown", + "description": "Entry body. A bare `string` is rendered as plain text; pass\n`{ markdown: \"…\" }` to opt into Markdown rendering. See\n{@link StringOrMarkdown}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "text" + ] + }, "TelemetryCapabilities": { "type": "object", "description": "OTLP telemetry channels the agent host emits.\n\nEach field, when present, is either a literal channel URI or an\n[RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI template\na client expands and then subscribes to. Absent fields indicate the host\ndoes not emit that signal.\n\nChannel URIs use the `ahp-otlp:` scheme. The scheme identifies the\nprotocol (OpenTelemetry over AHP) so clients can recognise the channel\ntype by URI alone; the host is free to choose any authority/path that\nmakes sense for its implementation. Clients MUST treat the URI as\nopaque (apart from expanding any well-known template variables defined\nbelow) and subscribe with the resulting concrete URI.\n\nPayloads delivered on these channels are OTLP/JSON values — see\n[opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto)\nfor the wire shapes (`ExportLogsServiceRequest`,\n`ExportTraceServiceRequest`, `ExportMetricsServiceRequest`).", @@ -6429,6 +6586,84 @@ "type" ] }, + "AnnotationsSetAction": { + "type": "object", + "description": "Upsert an {@link Annotation} in the annotations channel — adds a new\nannotation, or replaces an existing one identified by\n{@link Annotation.id}.\n\nDispatched by a client to create an annotation (together with its\nmandatory first entry) or to re-anchor / resolve an existing one; the\ndispatching client assigns the {@link Annotation.id} and the id of any\nnew entry. When replacing, the full annotation payload (including its\n{@link Annotation.entries | entries} list) is substituted; producers\nSHOULD prefer {@link AnnotationsEntrySetAction} for per-entry edits to\nkeep wire updates small.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.AnnotationsSet" + }, + "annotation": { + "$ref": "#/$defs/Annotation", + "description": "The new or replacement annotation. MUST contain at least one entry." + } + }, + "required": [ + "type", + "annotation" + ] + }, + "AnnotationsRemovedAction": { + "type": "object", + "description": "Remove an {@link Annotation} from the channel by its id.\n\nDispatched to delete an entire annotation and every entry it contains.\nBecause the protocol forbids empty annotations, a client that wants to\nremove the last remaining entry dispatches this action — collapsing the\nannotation — rather than {@link AnnotationsEntryRemovedAction}.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.AnnotationsRemoved" + }, + "annotationId": { + "type": "string", + "description": "The {@link Annotation.id} of the annotation to remove." + } + }, + "required": [ + "type", + "annotationId" + ] + }, + "AnnotationsEntrySetAction": { + "type": "object", + "description": "Upsert an {@link AnnotationEntry} within an existing annotation — adds a\nnew entry, or replaces one identified by {@link AnnotationEntry.id}. The\ndispatching client assigns the {@link AnnotationEntry.id} of a new entry.\nIf {@link annotationId} does not match any current annotation the action\nis a no-op.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.AnnotationsEntrySet" + }, + "annotationId": { + "type": "string", + "description": "The {@link Annotation.id} the entry belongs to." + }, + "entry": { + "$ref": "#/$defs/AnnotationEntry", + "description": "The new or replacement entry." + } + }, + "required": [ + "type", + "annotationId", + "entry" + ] + }, + "AnnotationsEntryRemovedAction": { + "type": "object", + "description": "Remove a single {@link AnnotationEntry} from an annotation without\ncollapsing the annotation itself. Used when more than one entry remains —\nto remove the last entry a client dispatches {@link AnnotationsRemovedAction}\ninstead, since the protocol forbids empty annotations.\n\nIf either {@link annotationId} or {@link entryId} does not match the\ncurrent state the action is a no-op.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.AnnotationsEntryRemoved" + }, + "annotationId": { + "type": "string", + "description": "The {@link Annotation.id} the entry belongs to." + }, + "entryId": { + "type": "string", + "description": "The {@link AnnotationEntry.id} to remove." + } + }, + "required": [ + "type", + "annotationId", + "entryId" + ] + }, "ResourceWatchChangedAction": { "type": "object", "description": "A batch of resource changes observed by the watcher.\n\nWatch events are coalesced into batches by the server to keep the\naction stream tractable; an empty `changes.items` list MUST NOT be\ndispatched. The reducer does not retain change history — these\nactions exist purely to deliver events to subscribers, who consume\nthem directly off the action stream and apply their own logic.", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 620c9e2f..d3329a2b 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -499,6 +499,9 @@ }, { "$ref": "#/$defs/ChangesetState" + }, + { + "$ref": "#/$defs/AnnotationsState" } ], "description": "The current state of the resource" @@ -880,6 +883,10 @@ "changes": { "$ref": "#/$defs/ChangesSummary", "description": "Aggregate summary of file changes associated with this session. Servers\nmay populate this to give clients a quick at-a-glance view of the\nsession's footprint (e.g., for list rendering) without requiring the\nclient to subscribe to a changeset." + }, + "annotations": { + "$ref": "#/$defs/AnnotationsSummary", + "description": "Lightweight summary of this session's inline annotations channel\n(`ahp-session://annotations`). Surfaced so badge UI can render\nannotation / entry counts without subscribing. Absent when the session\ndoes not expose an annotations channel." } }, "required": [ @@ -1761,6 +1768,49 @@ "type" ] }, + "MessageAnnotationsAttachment": { + "type": "object", + "description": "An attachment that references annotations on a session's annotations\nchannel (see {@link AnnotationsState}).\n\nWhen {@link annotationIds} is omitted the attachment references every\nannotation on the channel; when present it references only the listed\n{@link Annotation.id | annotation ids}.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "$ref": "#/$defs/MessageAttachmentKind.Annotations", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The annotations channel URI (typically `ahp-session://annotations`).\nMatches {@link AnnotationsSummary.resource}." + }, + "annotationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific {@link Annotation.id | annotation ids} to reference. When\nomitted, the attachment references all annotations on the channel." + } + }, + "required": [ + "label", + "type", + "resource" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -3773,6 +3823,113 @@ "status" ] }, + "AnnotationsSummary": { + "type": "object", + "description": "Lightweight per-session summary of the annotations channel, surfaced on\n{@link SessionSummary.annotations} so badge UI can render annotation /\nentry counts without subscribing to the channel itself.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "The subscribable annotations channel URI for the owning session\n(typically `ahp-session://annotations`). Surfaced explicitly even\nthough it is derivable from the session URI so badge UI does not need\nto know the derivation rule." + }, + "annotationCount": { + "type": "number", + "description": "Total number of {@link Annotation} entries in the channel." + }, + "entryCount": { + "type": "number", + "description": "Total number of {@link AnnotationEntry} entries across every annotation." + } + }, + "required": [ + "resource", + "annotationCount", + "entryCount" + ] + }, + "AnnotationsState": { + "type": "object", + "description": "Full state for a session's annotations channel, returned when a client\nsubscribes to an `ahp-session://annotations` URI.", + "properties": { + "annotations": { + "type": "array", + "items": { + "$ref": "#/$defs/Annotation" + }, + "description": "Annotations in this channel, keyed by {@link Annotation.id}." + } + }, + "required": [ + "annotations" + ] + }, + "Annotation": { + "type": "object", + "description": "A conversation anchored to a specific file produced by a specific turn,\noptionally narrowed to a range within that file.\n\n{@link turnId} anchors the annotation to the file versions that turn\nproduced, so a later turn that rewrites the same file does not silently\ninvalidate the annotation's anchor — clients can resolve {@link resource}\nand {@link range} against the turn's changeset. When {@link range} is\nomitted the annotation is anchored to the entire file.\n\nEvery annotation MUST contain at least one {@link AnnotationEntry}. An\n{@link AnnotationsSetAction} that creates an annotation therefore carries\nits mandatory first entry, and removing the last remaining entry collapses\nthe annotation via {@link AnnotationsRemovedAction} rather than leaving an\nempty annotation behind.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the annotations channel. Assigned by the client\nthat dispatches the creating {@link AnnotationsSetAction}." + }, + "turnId": { + "type": "string", + "description": "Turn that produced the file versions this annotation is anchored to.\nMatches a {@link Turn.id} on the owning session." + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The file the annotation is anchored to." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within {@link resource} the annotation is anchored to. When\nomitted the annotation is anchored to the entire file." + }, + "resolved": { + "type": "boolean", + "description": "Whether the annotation has been resolved. Newly created annotations are\nalways unresolved (`false`); a client marks an annotation resolved (or\nre-opens it) by dispatching an {@link AnnotationsSetAction} carrying the\nupdated flag." + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/AnnotationEntry" + }, + "description": "Entries in this annotation, in dispatch order (oldest first). MUST\ncontain at least one entry." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "turnId", + "resource", + "resolved", + "entries" + ] + }, + "AnnotationEntry": { + "type": "object", + "description": "A single entry within an {@link Annotation}.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the enclosing annotation. Assigned by the client\nthat dispatches the {@link AnnotationsEntrySetAction} (or the enclosing\n{@link AnnotationsSetAction}) introducing the entry." + }, + "text": { + "$ref": "#/$defs/StringOrMarkdown", + "description": "Entry body. A bare `string` is rendered as plain text; pass\n`{ markdown: \"…\" }` to opt into Markdown rendering. See\n{@link StringOrMarkdown}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "text" + ] + }, "TelemetryCapabilities": { "type": "object", "description": "OTLP telemetry channels the agent host emits.\n\nEach field, when present, is either a literal channel URI or an\n[RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI template\na client expands and then subscribes to. Absent fields indicate the host\ndoes not emit that signal.\n\nChannel URIs use the `ahp-otlp:` scheme. The scheme identifies the\nprotocol (OpenTelemetry over AHP) so clients can recognise the channel\ntype by URI alone; the host is free to choose any authority/path that\nmakes sense for its implementation. Clients MUST treat the URI as\nopaque (apart from expanding any well-known template variables defined\nbelow) and subscribe with the resulting concrete URI.\n\nPayloads delivered on these channels are OTLP/JSON values — see\n[opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto)\nfor the wire shapes (`ExportLogsServiceRequest`,\n`ExportTraceServiceRequest`, `ExportMetricsServiceRequest`).", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 5e5fdb12..56e16fc9 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -125,6 +125,10 @@ "changes": { "$ref": "#/$defs/ChangesSummary", "description": "Aggregate summary of file changes associated with this session. Servers\nmay populate this to give clients a quick at-a-glance view of the\nsession's footprint (e.g., for list rendering) without requiring the\nclient to subscribe to a changeset." + }, + "annotations": { + "$ref": "#/$defs/AnnotationsSummary", + "description": "Lightweight summary of this session's inline annotations channel\n(`ahp-session://annotations`). Surfaced so badge UI can render\nannotation / entry counts without subscribing. Absent when the session\ndoes not expose an annotations channel." } }, "description": "Mutable summary fields that changed; omitted fields are unchanged.\n\nIdentity fields (`resource`, `provider`, `createdAt`) never change and\nMUST be omitted by senders; receivers SHOULD ignore them if present." @@ -624,6 +628,9 @@ }, { "$ref": "#/$defs/ChangesetState" + }, + { + "$ref": "#/$defs/AnnotationsState" } ], "description": "The current state of the resource" @@ -1005,6 +1012,10 @@ "changes": { "$ref": "#/$defs/ChangesSummary", "description": "Aggregate summary of file changes associated with this session. Servers\nmay populate this to give clients a quick at-a-glance view of the\nsession's footprint (e.g., for list rendering) without requiring the\nclient to subscribe to a changeset." + }, + "annotations": { + "$ref": "#/$defs/AnnotationsSummary", + "description": "Lightweight summary of this session's inline annotations channel\n(`ahp-session://annotations`). Surfaced so badge UI can render\nannotation / entry counts without subscribing. Absent when the session\ndoes not expose an annotations channel." } }, "required": [ @@ -1886,6 +1897,49 @@ "type" ] }, + "MessageAnnotationsAttachment": { + "type": "object", + "description": "An attachment that references annotations on a session's annotations\nchannel (see {@link AnnotationsState}).\n\nWhen {@link annotationIds} is omitted the attachment references every\nannotation on the channel; when present it references only the listed\n{@link Annotation.id | annotation ids}.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "$ref": "#/$defs/MessageAttachmentKind.Annotations", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The annotations channel URI (typically `ahp-session://annotations`).\nMatches {@link AnnotationsSummary.resource}." + }, + "annotationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific {@link Annotation.id | annotation ids} to reference. When\nomitted, the attachment references all annotations on the channel." + } + }, + "required": [ + "label", + "type", + "resource" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -3898,6 +3952,113 @@ "status" ] }, + "AnnotationsSummary": { + "type": "object", + "description": "Lightweight per-session summary of the annotations channel, surfaced on\n{@link SessionSummary.annotations} so badge UI can render annotation /\nentry counts without subscribing to the channel itself.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "The subscribable annotations channel URI for the owning session\n(typically `ahp-session://annotations`). Surfaced explicitly even\nthough it is derivable from the session URI so badge UI does not need\nto know the derivation rule." + }, + "annotationCount": { + "type": "number", + "description": "Total number of {@link Annotation} entries in the channel." + }, + "entryCount": { + "type": "number", + "description": "Total number of {@link AnnotationEntry} entries across every annotation." + } + }, + "required": [ + "resource", + "annotationCount", + "entryCount" + ] + }, + "AnnotationsState": { + "type": "object", + "description": "Full state for a session's annotations channel, returned when a client\nsubscribes to an `ahp-session://annotations` URI.", + "properties": { + "annotations": { + "type": "array", + "items": { + "$ref": "#/$defs/Annotation" + }, + "description": "Annotations in this channel, keyed by {@link Annotation.id}." + } + }, + "required": [ + "annotations" + ] + }, + "Annotation": { + "type": "object", + "description": "A conversation anchored to a specific file produced by a specific turn,\noptionally narrowed to a range within that file.\n\n{@link turnId} anchors the annotation to the file versions that turn\nproduced, so a later turn that rewrites the same file does not silently\ninvalidate the annotation's anchor — clients can resolve {@link resource}\nand {@link range} against the turn's changeset. When {@link range} is\nomitted the annotation is anchored to the entire file.\n\nEvery annotation MUST contain at least one {@link AnnotationEntry}. An\n{@link AnnotationsSetAction} that creates an annotation therefore carries\nits mandatory first entry, and removing the last remaining entry collapses\nthe annotation via {@link AnnotationsRemovedAction} rather than leaving an\nempty annotation behind.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the annotations channel. Assigned by the client\nthat dispatches the creating {@link AnnotationsSetAction}." + }, + "turnId": { + "type": "string", + "description": "Turn that produced the file versions this annotation is anchored to.\nMatches a {@link Turn.id} on the owning session." + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The file the annotation is anchored to." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within {@link resource} the annotation is anchored to. When\nomitted the annotation is anchored to the entire file." + }, + "resolved": { + "type": "boolean", + "description": "Whether the annotation has been resolved. Newly created annotations are\nalways unresolved (`false`); a client marks an annotation resolved (or\nre-opens it) by dispatching an {@link AnnotationsSetAction} carrying the\nupdated flag." + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/AnnotationEntry" + }, + "description": "Entries in this annotation, in dispatch order (oldest first). MUST\ncontain at least one entry." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "turnId", + "resource", + "resolved", + "entries" + ] + }, + "AnnotationEntry": { + "type": "object", + "description": "A single entry within an {@link Annotation}.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the enclosing annotation. Assigned by the client\nthat dispatches the {@link AnnotationsEntrySetAction} (or the enclosing\n{@link AnnotationsSetAction}) introducing the entry." + }, + "text": { + "$ref": "#/$defs/StringOrMarkdown", + "description": "Entry body. A bare `string` is rendered as plain text; pass\n`{ markdown: \"…\" }` to opt into Markdown rendering. See\n{@link StringOrMarkdown}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "text" + ] + }, "TelemetryCapabilities": { "type": "object", "description": "OTLP telemetry channels the agent host emits.\n\nEach field, when present, is either a literal channel URI or an\n[RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI template\na client expands and then subscribes to. Absent fields indicate the host\ndoes not emit that signal.\n\nChannel URIs use the `ahp-otlp:` scheme. The scheme identifies the\nprotocol (OpenTelemetry over AHP) so clients can recognise the channel\ntype by URI alone; the host is free to choose any authority/path that\nmakes sense for its implementation. Clients MUST treat the URI as\nopaque (apart from expanding any well-known template variables defined\nbelow) and subscribe with the resulting concrete URI.\n\nPayloads delivered on these channels are OTLP/JSON values — see\n[opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto)\nfor the wire shapes (`ExportLogsServiceRequest`,\n`ExportTraceServiceRequest`, `ExportMetricsServiceRequest`).", diff --git a/schema/state.schema.json b/schema/state.schema.json index ecd4de76..52b6e5f4 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -410,6 +410,9 @@ }, { "$ref": "#/$defs/ChangesetState" + }, + { + "$ref": "#/$defs/AnnotationsState" } ], "description": "The current state of the resource" @@ -791,6 +794,10 @@ "changes": { "$ref": "#/$defs/ChangesSummary", "description": "Aggregate summary of file changes associated with this session. Servers\nmay populate this to give clients a quick at-a-glance view of the\nsession's footprint (e.g., for list rendering) without requiring the\nclient to subscribe to a changeset." + }, + "annotations": { + "$ref": "#/$defs/AnnotationsSummary", + "description": "Lightweight summary of this session's inline annotations channel\n(`ahp-session://annotations`). Surfaced so badge UI can render\nannotation / entry counts without subscribing. Absent when the session\ndoes not expose an annotations channel." } }, "required": [ @@ -1672,6 +1679,49 @@ "type" ] }, + "MessageAnnotationsAttachment": { + "type": "object", + "description": "An attachment that references annotations on a session's annotations\nchannel (see {@link AnnotationsState}).\n\nWhen {@link annotationIds} is omitted the attachment references every\nannotation on the channel; when present it references only the listed\n{@link Annotation.id | annotation ids}.", + "properties": { + "label": { + "type": "string", + "description": "A human-readable label for the attachment (e.g. the filename of a file\nattachment). Used for display in UI." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "If defined, the range in {@link Message.text} that references this\nattachment. This is a text range, not a byte range." + }, + "displayKind": { + "type": "string", + "description": "Advisory display hint for clients rendering this attachment. Recognized\nvalues include:\n\n- `'image'`: the attachment is an image\n- `'document'`: the attachment is a textual document\n- `'symbol'`: the attachment is a code symbol (e.g. a function or class)\n- `'directory'`: the attachment is a folder\n- `'selection'`: the attachment is a selection within a document\n\nImplementations MAY provide additional values; clients SHOULD fall back\nto a reasonable default when an unknown value is encountered." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Additional implementation-defined metadata for the attachment.\n\nIf the attachment was produced by the `completions` command, the client\nMUST preserve every property of `_meta` originally returned by the agent\nhost when sending the user message containing the accepted completion." + }, + "type": { + "$ref": "#/$defs/MessageAttachmentKind.Annotations", + "description": "Discriminant" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The annotations channel URI (typically `ahp-session://annotations`).\nMatches {@link AnnotationsSummary.resource}." + }, + "annotationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific {@link Annotation.id | annotation ids} to reference. When\nomitted, the attachment references all annotations on the channel." + } + }, + "required": [ + "label", + "type", + "resource" + ] + }, "MarkdownResponsePart": { "type": "object", "properties": { @@ -3684,6 +3734,113 @@ "status" ] }, + "AnnotationsSummary": { + "type": "object", + "description": "Lightweight per-session summary of the annotations channel, surfaced on\n{@link SessionSummary.annotations} so badge UI can render annotation /\nentry counts without subscribing to the channel itself.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "The subscribable annotations channel URI for the owning session\n(typically `ahp-session://annotations`). Surfaced explicitly even\nthough it is derivable from the session URI so badge UI does not need\nto know the derivation rule." + }, + "annotationCount": { + "type": "number", + "description": "Total number of {@link Annotation} entries in the channel." + }, + "entryCount": { + "type": "number", + "description": "Total number of {@link AnnotationEntry} entries across every annotation." + } + }, + "required": [ + "resource", + "annotationCount", + "entryCount" + ] + }, + "AnnotationsState": { + "type": "object", + "description": "Full state for a session's annotations channel, returned when a client\nsubscribes to an `ahp-session://annotations` URI.", + "properties": { + "annotations": { + "type": "array", + "items": { + "$ref": "#/$defs/Annotation" + }, + "description": "Annotations in this channel, keyed by {@link Annotation.id}." + } + }, + "required": [ + "annotations" + ] + }, + "Annotation": { + "type": "object", + "description": "A conversation anchored to a specific file produced by a specific turn,\noptionally narrowed to a range within that file.\n\n{@link turnId} anchors the annotation to the file versions that turn\nproduced, so a later turn that rewrites the same file does not silently\ninvalidate the annotation's anchor — clients can resolve {@link resource}\nand {@link range} against the turn's changeset. When {@link range} is\nomitted the annotation is anchored to the entire file.\n\nEvery annotation MUST contain at least one {@link AnnotationEntry}. An\n{@link AnnotationsSetAction} that creates an annotation therefore carries\nits mandatory first entry, and removing the last remaining entry collapses\nthe annotation via {@link AnnotationsRemovedAction} rather than leaving an\nempty annotation behind.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the annotations channel. Assigned by the client\nthat dispatches the creating {@link AnnotationsSetAction}." + }, + "turnId": { + "type": "string", + "description": "Turn that produced the file versions this annotation is anchored to.\nMatches a {@link Turn.id} on the owning session." + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "The file the annotation is anchored to." + }, + "range": { + "$ref": "#/$defs/TextRange", + "description": "Range within {@link resource} the annotation is anchored to. When\nomitted the annotation is anchored to the entire file." + }, + "resolved": { + "type": "boolean", + "description": "Whether the annotation has been resolved. Newly created annotations are\nalways unresolved (`false`); a client marks an annotation resolved (or\nre-opens it) by dispatching an {@link AnnotationsSetAction} carrying the\nupdated flag." + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/AnnotationEntry" + }, + "description": "Entries in this annotation, in dispatch order (oldest first). MUST\ncontain at least one entry." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "turnId", + "resource", + "resolved", + "entries" + ] + }, + "AnnotationEntry": { + "type": "object", + "description": "A single entry within an {@link Annotation}.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier within the enclosing annotation. Assigned by the client\nthat dispatches the {@link AnnotationsEntrySetAction} (or the enclosing\n{@link AnnotationsSetAction}) introducing the entry." + }, + "text": { + "$ref": "#/$defs/StringOrMarkdown", + "description": "Entry body. A bare `string` is rendered as plain text; pass\n`{ markdown: \"…\" }` to opt into Markdown rendering. See\n{@link StringOrMarkdown}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Producer-defined opaque metadata, surfaced to tooling but not\ninterpreted by the protocol." + } + }, + "required": [ + "id", + "text" + ] + }, "TelemetryCapabilities": { "type": "object", "description": "OTLP telemetry channels the agent host emits.\n\nEach field, when present, is either a literal channel URI or an\n[RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI template\na client expands and then subscribes to. Absent fields indicate the host\ndoes not emit that signal.\n\nChannel URIs use the `ahp-otlp:` scheme. The scheme identifies the\nprotocol (OpenTelemetry over AHP) so clients can recognise the channel\ntype by URI alone; the host is free to choose any authority/path that\nmakes sense for its implementation. Clients MUST treat the URI as\nopaque (apart from expanding any well-known template variables defined\nbelow) and subscribe with the resulting concrete URI.\n\nPayloads delivered on these channels are OTLP/JSON values — see\n[opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto)\nfor the wire shapes (`ExportLogsServiceRequest`,\n`ExportTraceServiceRequest`, `ExportMetricsServiceRequest`).", @@ -3842,6 +3999,9 @@ }, { "$ref": "#/$defs/MessageResourceAttachment" + }, + { + "$ref": "#/$defs/MessageAnnotationsAttachment" } ], "description": "An attachment associated with a {@link Message}." diff --git a/scripts/find-protocol-sources.ts b/scripts/find-protocol-sources.ts index da7cac8a..40777394 100644 --- a/scripts/find-protocol-sources.ts +++ b/scripts/find-protocol-sources.ts @@ -20,6 +20,7 @@ export const PROTOCOL_SOURCE_DIRS: readonly string[] = [ 'channels-session', 'channels-terminal', 'channels-changeset', + 'channels-annotations', 'channels-otlp', 'channels-resource-watch', ]; diff --git a/scripts/generate-action-origin.ts b/scripts/generate-action-origin.ts index e976980f..b641f496 100644 --- a/scripts/generate-action-origin.ts +++ b/scripts/generate-action-origin.ts @@ -17,7 +17,7 @@ const GENERATED_HEADER = `// Generated from types/actions.ts — do not edit // Run \`npm run generate\` to regenerate. `; -type ActionScope = 'root' | 'session' | 'terminal' | 'changeset' | 'resourceWatch'; +type ActionScope = 'root' | 'session' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch'; interface ActionInfo { /** The interface name (e.g. 'RootAgentsChangedAction') */ @@ -152,6 +152,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { const scope: ActionScope = category === 'Root Actions' ? 'root' : category === 'Terminal Actions' ? 'terminal' : category === 'Changeset Actions' ? 'changeset' + : category === 'Annotations Actions' ? 'annotations' : category === 'Resource Watch Actions' ? 'resourceWatch' : 'session'; const isClientDispatchable = hasJsDocTag(node as any, 'clientDispatchable'); @@ -202,6 +203,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { const sessionActions = actions.filter(a => a.scope === 'session'); const terminalActions = actions.filter(a => a.scope === 'terminal'); const changesetActions = actions.filter(a => a.scope === 'changeset'); + const annotationsActions = actions.filter(a => a.scope === 'annotations'); const resourceWatchActions = actions.filter(a => a.scope === 'resourceWatch'); const clientRootActions = rootActions.filter(a => a.isClientDispatchable); const serverRootActions = rootActions.filter(a => !a.isClientDispatchable); @@ -211,6 +213,8 @@ export function generateActionOrigin(project: Project, outDir: string): void { const serverTerminalActions = terminalActions.filter(a => !a.isClientDispatchable); const clientChangesetActions = changesetActions.filter(a => a.isClientDispatchable); const serverChangesetActions = changesetActions.filter(a => !a.isClientDispatchable); + const clientAnnotationsActions = annotationsActions.filter(a => a.isClientDispatchable); + const serverAnnotationsActions = annotationsActions.filter(a => !a.isClientDispatchable); const clientResourceWatchActions = resourceWatchActions.filter(a => a.isClientDispatchable); const serverResourceWatchActions = resourceWatchActions.filter(a => !a.isClientDispatchable); @@ -345,6 +349,45 @@ export function generateActionOrigin(project: Project, outDir: string): void { lines.push(`;`); lines.push(``); + // AnnotationsAction + lines.push(`/** Union of all annotations-scoped actions. */`); + lines.push(`export type AnnotationsAction =`); + if (annotationsActions.length === 0) { + lines.push(` never`); + } else { + for (let i = 0; i < annotationsActions.length; i++) { + lines.push(` | ${annotationsActions[i].name}`); + } + } + lines.push(`;`); + lines.push(``); + + // ClientAnnotationsAction + lines.push(`/** Union of annotations actions that clients may dispatch. */`); + lines.push(`export type ClientAnnotationsAction =`); + if (clientAnnotationsActions.length === 0) { + lines.push(` never`); + } else { + for (let i = 0; i < clientAnnotationsActions.length; i++) { + lines.push(` | ${clientAnnotationsActions[i].name}`); + } + } + lines.push(`;`); + lines.push(``); + + // ServerAnnotationsAction + lines.push(`/** Union of annotations actions that only the server may produce. */`); + lines.push(`export type ServerAnnotationsAction =`); + if (serverAnnotationsActions.length === 0) { + lines.push(` never`); + } else { + for (let i = 0; i < serverAnnotationsActions.length; i++) { + lines.push(` | ${serverAnnotationsActions[i].name}`); + } + } + lines.push(`;`); + lines.push(``); + // ResourceWatchAction lines.push(`/** Union of all resource-watch-scoped actions. */`); lines.push(`export type ResourceWatchAction =`); diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index e1adfe8d..0945e6cd 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -158,6 +158,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState' || tsType === 'RootState | SessionState | TerminalState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' ) { return 'SnapshotState'; } @@ -679,6 +680,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'SimpleMessageAttachment' }, { name: 'MessageEmbeddedResourceAttachment' }, { name: 'MessageResourceAttachment' }, + { name: 'MessageAnnotationsAttachment' }, { name: 'MarkdownResponsePart' }, { name: 'ContentRef' }, { name: 'ResourceReponsePart', goName: 'ResourceResponsePart' }, @@ -737,6 +739,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'ChangesetState' }, { name: 'ChangesetFile' }, { name: 'ChangesetOperation' }, + { name: 'AnnotationsSummary' }, + { name: 'AnnotationsState' }, + { name: 'Annotation' }, + { name: 'AnnotationEntry' }, { name: 'TelemetryCapabilities' }, { name: 'ResourceWatchState' }, { name: 'ResourceChange' }, @@ -857,6 +863,7 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { { variantName: 'Simple', innerType: 'SimpleMessageAttachment', wireValue: 'simple' }, { variantName: 'EmbeddedResource', innerType: 'MessageEmbeddedResourceAttachment', wireValue: 'embeddedResource' }, { variantName: 'Resource', innerType: 'MessageResourceAttachment', wireValue: 'resource' }, + { variantName: 'Annotations', innerType: 'MessageAnnotationsAttachment', wireValue: 'annotations' }, ], unknown: true, }; @@ -928,14 +935,15 @@ const TOOL_CALL_CONTRIBUTOR_UNION: UnionConfig = { function generateSnapshotState(): string { return `// SnapshotState is the state payload of a snapshot — root, session, -// terminal, or changeset state. The active variant is chosen by which +// terminal, changeset, or annotations state. The active variant is chosen by which // pointer field is non-nil; UnmarshalJSON probes for required fields in -// the canonical order (session → terminal → changeset → root). +// the canonical order (session → terminal → changeset → annotations → root). type SnapshotState struct { \tRoot *RootState \`json:"-"\` \tSession *SessionState \`json:"-"\` \tTerminal *TerminalState \`json:"-"\` \tChangeset *ChangesetState \`json:"-"\` + Annotations *AnnotationsState \`json:"-"\` } // MarshalJSON encodes whichever variant is currently populated. @@ -947,6 +955,8 @@ func (s SnapshotState) MarshalJSON() ([]byte, error) { \t\treturn json.Marshal(s.Terminal) \tcase s.Changeset != nil: \t\treturn json.Marshal(s.Changeset) + case s.Annotations != nil: + return json.Marshal(s.Annotations) \tcase s.Root != nil: \t\treturn json.Marshal(s.Root) \tdefault: @@ -981,6 +991,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { \t\t\treturn err \t\t} \t\ts.Changeset = &v + case containsAll(probe, "annotations"): + var v AnnotationsState + if err := json.Unmarshal(data, &v); err != nil { + return err + } + s.Annotations = &v \tdefault: \t\tvar v RootState \t\tif err := json.Unmarshal(data, &v); err != nil { @@ -1120,6 +1136,10 @@ const ACTION_VARIANTS: { { type: 'changeset/operationsChanged', variantName: 'ChangesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, { type: 'changeset/operationStatusChanged', variantName: 'ChangesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, { type: 'changeset/cleared', variantName: 'ChangesetCleared', tsInterface: 'ChangesetClearedAction' }, + { type: 'annotations/set', variantName: 'AnnotationsSet', tsInterface: 'AnnotationsSetAction' }, + { type: 'annotations/removed', variantName: 'AnnotationsRemoved', tsInterface: 'AnnotationsRemovedAction' }, + { type: 'annotations/entrySet', variantName: 'AnnotationsEntrySet', tsInterface: 'AnnotationsEntrySetAction' }, + { type: 'annotations/entryRemoved', variantName: 'AnnotationsEntryRemoved', tsInterface: 'AnnotationsEntryRemovedAction' }, { type: 'root/terminalsChanged', variantName: 'RootTerminalsChanged', tsInterface: 'RootTerminalsChangedAction' }, { type: 'terminal/data', variantName: 'TerminalData', tsInterface: 'TerminalDataAction' }, { type: 'terminal/input', variantName: 'TerminalInput', tsInterface: 'TerminalInputAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index ef71778d..09df368a 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -141,6 +141,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState' || tsType === 'RootState | SessionState | TerminalState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' ) { return 'SnapshotState'; } @@ -323,7 +324,7 @@ function emitKDoc(doc: string, indent = ''): string[] { // comment that never closes. Insert a zero-width space (U+200B) to break // the token without changing the rendered output. const safe = line.trim().replace(/\/\*/g, '/\u200B*').replace(/\*\//g, '*\u200B/'); - out.push(`${indent} * ${safe}`); + out.push(safe ? `${indent} * ${safe}` : `${indent} *`); } out.push(`${indent} */`); return out; @@ -636,7 +637,8 @@ internal object StringOrMarkdownSerializer : KSerializer { function generateSnapshotState(): string { return `/** - * The state payload of a snapshot — root, session, terminal, or changeset state. + * The state payload of a snapshot — root, session, terminal, changeset, + * or annotations state. */ @Serializable(with = SnapshotStateSerializer::class) sealed interface SnapshotState { @@ -644,6 +646,7 @@ sealed interface SnapshotState { @JvmInline value class Session(val value: SessionState) : SnapshotState @JvmInline value class Terminal(val value: TerminalState) : SnapshotState @JvmInline value class Changeset(val value: ChangesetState) : SnapshotState + @JvmInline value class Annotations(val value: AnnotationsState) : SnapshotState } internal object SnapshotStateSerializer : KSerializer { @@ -658,12 +661,14 @@ internal object SnapshotStateSerializer : KSerializer { ?: error("Expected JsonObject for SnapshotState") // Try the most distinctive shape first. SessionState has required // \`summary\`; ChangesetState has required \`status\` + \`files\`; - // TerminalState has \`uri\` / \`size\` / \`buffer\`; RootState is the - // catch-all. + // AnnotationsState has required \`annotations\`; TerminalState has \`uri\` + // / \`size\` / \`buffer\`; RootState is the catch-all. return when { obj.containsKey("summary") -> SnapshotState.Session(input.json.decodeFromJsonElement(SessionState.serializer(), element)) obj.containsKey("status") && obj.containsKey("files") -> SnapshotState.Changeset(input.json.decodeFromJsonElement(ChangesetState.serializer(), element)) + obj.containsKey("annotations") -> + SnapshotState.Annotations(input.json.decodeFromJsonElement(AnnotationsState.serializer(), element)) obj.containsKey("size") || obj.containsKey("uri") || obj.containsKey("buffer") -> SnapshotState.Terminal(input.json.decodeFromJsonElement(TerminalState.serializer(), element)) else -> SnapshotState.Root(input.json.decodeFromJsonElement(RootState.serializer(), element)) @@ -678,6 +683,7 @@ internal object SnapshotStateSerializer : KSerializer { is SnapshotState.Session -> output.json.encodeToJsonElement(SessionState.serializer(), value.value) is SnapshotState.Terminal -> output.json.encodeToJsonElement(TerminalState.serializer(), value.value) is SnapshotState.Changeset -> output.json.encodeToJsonElement(ChangesetState.serializer(), value.value) + is SnapshotState.Annotations -> output.json.encodeToJsonElement(AnnotationsState.serializer(), value.value) } output.encodeJsonElement(element) } @@ -773,6 +779,7 @@ const STATE_STRUCTS = [ 'SessionInputRequest', 'TextPosition', 'TextRange', 'TextSelection', 'SimpleMessageAttachment', 'MessageEmbeddedResourceAttachment', 'MessageResourceAttachment', + 'MessageAnnotationsAttachment', 'MarkdownResponsePart', 'ContentRef', 'ResourceReponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', @@ -797,6 +804,7 @@ const STATE_STRUCTS = [ 'TerminalUnclassifiedPart', 'TerminalCommandPart', 'UsageInfo', 'ErrorInfo', 'Snapshot', 'Changeset', 'ChangesetState', 'ChangesetFile', 'ChangesetOperation', + 'AnnotationsSummary', 'AnnotationsState', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', 'ResourceWatchState', 'ResourceChange', ]; @@ -895,6 +903,7 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { { caseName: 'Simple', structName: 'SimpleMessageAttachment', discriminantValue: 'simple' }, { caseName: 'EmbeddedResource', structName: 'MessageEmbeddedResourceAttachment', discriminantValue: 'embeddedResource' }, { caseName: 'Resource', structName: 'MessageResourceAttachment', discriminantValue: 'resource' }, + { caseName: 'Annotations', structName: 'MessageAnnotationsAttachment', discriminantValue: 'annotations' }, ], unknown: true, }; @@ -1082,6 +1091,10 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'changeset/operationsChanged', caseName: 'ChangesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, { type: 'changeset/operationStatusChanged', caseName: 'ChangesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, { type: 'changeset/cleared', caseName: 'ChangesetCleared', tsInterface: 'ChangesetClearedAction' }, + { type: 'annotations/set', caseName: 'AnnotationsSet', tsInterface: 'AnnotationsSetAction' }, + { type: 'annotations/removed', caseName: 'AnnotationsRemoved', tsInterface: 'AnnotationsRemovedAction' }, + { type: 'annotations/entrySet', caseName: 'AnnotationsEntrySet', tsInterface: 'AnnotationsEntrySetAction' }, + { type: 'annotations/entryRemoved', caseName: 'AnnotationsEntryRemoved', tsInterface: 'AnnotationsEntryRemovedAction' }, { type: 'root/terminalsChanged', caseName: 'RootTerminalsChanged', tsInterface: 'RootTerminalsChangedAction' }, { type: 'root/configChanged', caseName: 'RootConfigChanged', tsInterface: 'RootConfigChangedAction' }, { type: 'terminal/data', caseName: 'TerminalData', tsInterface: 'TerminalDataAction' }, diff --git a/scripts/generate-markdown.ts b/scripts/generate-markdown.ts index 36a69356..5e83baef 100644 --- a/scripts/generate-markdown.ts +++ b/scripts/generate-markdown.ts @@ -53,6 +53,7 @@ const DIR_TO_PAGE: Record = { 'channels-session': 'session', 'channels-terminal': 'terminal', 'channels-changeset': 'changeset', + 'channels-annotations': 'annotations', 'channels-otlp': 'otlp', }; @@ -1003,6 +1004,35 @@ function generateChangesetChannelPage(project: Project): string { return lines.join('\n'); } +function generateAnnotationsChannelPage(project: Project): string { + currentPage = 'annotations'; + const stateSf = findChannelSourceFile(project, 'channels-annotations', 'state.ts'); + const actionsSf = findChannelSourceFile(project, 'channels-annotations', 'actions.ts'); + const commandsSf = findChannelSourceFile(project, 'channels-annotations', 'commands.ts'); + + const lines: string[] = [GENERATED_HEADER]; + lines.push('# Annotations Channel\n'); + lines.push('Reference for the `ahp-session://annotations` channel — per-session annotations anchored to file ranges within a session turn. Clients (and the agent host) mutate annotations by dispatching the client-dispatchable `annotations/*` state actions, which the write-ahead reducer applies identically on both peers.\n'); + lines.push(schemaLink('state.schema.json')); + + if (stateSf) { + lines.push('## State Types\n'); + lines.push(emitStateTypesSection([stateSf])); + } + if (actionsSf) { + lines.push('## Actions\n'); + lines.push('Mutate `AnnotationsState`. Scoped to an annotations channel URI via the enclosing `ActionEnvelope.channel`.\n'); + lines.push(schemaLink('actions.schema.json')); + lines.push(emitActionsSection([actionsSf])); + } + if (commandsSf) { + lines.push('## Commands\n'); + lines.push(schemaLink('commands.schema.json')); + lines.push(emitCommandsSection(project, [commandsSf])); + } + return lines.join('\n'); +} + function generateOtlpChannelPage(project: Project): string { currentPage = 'otlp'; const stateSf = findChannelSourceFile(project, 'channels-otlp', 'state.ts'); @@ -1243,6 +1273,7 @@ export function generateMarkdownDocs(project: Project, outDir: string): void { { filename: 'session.md', generator: generateSessionChannelPage }, { filename: 'terminal.md', generator: generateTerminalChannelPage }, { filename: 'changeset.md', generator: generateChangesetChannelPage }, + { filename: 'annotations.md', generator: generateAnnotationsChannelPage }, { filename: 'otlp.md', generator: generateOtlpChannelPage }, { filename: 'messages.md', generator: generateMessagesPage }, { filename: 'error-codes.md', generator: generateErrorCodesPage }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index bfa7e106..e1c418e8 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -146,7 +146,8 @@ function mapType(tsType: string, propName?: string, containerName?: string): str if (tsType === 'IRootState | ISessionState' || tsType === 'IRootState | ISessionState | ITerminalState' || tsType === 'RootState | SessionState' || tsType === 'RootState | SessionState | TerminalState' - || tsType === 'RootState | SessionState | TerminalState | ChangesetState') { + || tsType === 'RootState | SessionState | TerminalState | ChangesetState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState') { return 'SnapshotState'; } @@ -579,6 +580,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'SimpleMessageAttachment', omitDiscriminants: true }, { name: 'MessageEmbeddedResourceAttachment', omitDiscriminants: true }, { name: 'MessageResourceAttachment', omitDiscriminants: true }, + { name: 'MessageAnnotationsAttachment', omitDiscriminants: true }, { name: 'MarkdownResponsePart', omitDiscriminants: true }, { name: 'ContentRef' }, { name: 'ResourceReponsePart', omitDiscriminants: true, rustName: 'ResourceResponsePart' }, @@ -637,6 +639,10 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ChangesetState' }, { name: 'ChangesetFile' }, { name: 'ChangesetOperation' }, + { name: 'AnnotationsSummary' }, + { name: 'AnnotationsState' }, + { name: 'Annotation' }, + { name: 'AnnotationEntry' }, { name: 'TelemetryCapabilities' }, { name: 'ResourceWatchState' }, { name: 'ResourceChange' }, @@ -757,6 +763,7 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { { variantName: 'Simple', innerType: 'SimpleMessageAttachment', wireValue: 'simple' }, { variantName: 'EmbeddedResource', innerType: 'MessageEmbeddedResourceAttachment', wireValue: 'embeddedResource' }, { variantName: 'Resource', innerType: 'MessageResourceAttachment', wireValue: 'resource' }, + { variantName: 'Annotations', innerType: 'MessageAnnotationsAttachment', wireValue: 'annotations' }, ], unknown: true, }; @@ -832,18 +839,20 @@ const TOOL_CALL_CONTRIBUTOR_UNION: UnionConfig = { }; function generateSnapshotState(): string { - return `/// The state payload of a snapshot — root, session, terminal, or -/// changeset state. + return `/// The state payload of a snapshot — root, session, terminal, +/// changeset, or annotations state. /// /// Deserialized by trying session first (has required \`summary\`), then /// terminal (has required \`content\`), then changeset (has required -/// \`status\` and \`files\`), then root. +/// \`status\` and \`files\`), then annotations (has required \`annotations\`), +/// then root. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum SnapshotState { Session(Box), Terminal(Box), Changeset(Box), + Annotations(Box), Root(Box), }`; } @@ -968,6 +977,10 @@ const ACTION_VARIANTS: { { type: 'changeset/operationsChanged', variantName: 'ChangesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, { type: 'changeset/operationStatusChanged', variantName: 'ChangesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, { type: 'changeset/cleared', variantName: 'ChangesetCleared', tsInterface: 'ChangesetClearedAction' }, + { type: 'annotations/set', variantName: 'AnnotationsSet', tsInterface: 'AnnotationsSetAction' }, + { type: 'annotations/removed', variantName: 'AnnotationsRemoved', tsInterface: 'AnnotationsRemovedAction' }, + { type: 'annotations/entrySet', variantName: 'AnnotationsEntrySet', tsInterface: 'AnnotationsEntrySetAction' }, + { type: 'annotations/entryRemoved', variantName: 'AnnotationsEntryRemoved', tsInterface: 'AnnotationsEntryRemovedAction' }, { type: 'root/terminalsChanged', variantName: 'RootTerminalsChanged', tsInterface: 'RootTerminalsChangedAction' }, { type: 'terminal/data', variantName: 'TerminalData', tsInterface: 'TerminalDataAction' }, { type: 'terminal/input', variantName: 'TerminalInput', tsInterface: 'TerminalInputAction' }, @@ -1018,7 +1031,7 @@ pub struct SessionToolCallConfirmedAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; - lines.push('use crate::state::{AgentInfo, AgentSelection, ConfirmationOption, Customization, ErrorInfo, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, TerminalClaim, TerminalInfo, ToolCallContributor, ToolCallResult, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolResultContent, UsageInfo, Message, PendingMessageKind, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ConfirmationOption, Customization, ErrorInfo, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, TerminalClaim, TerminalInfo, ToolCallContributor, ToolCallResult, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolResultContent, UsageInfo, Message, PendingMessageKind, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset};'); lines.push(''); // ActionType enum @@ -1145,7 +1158,7 @@ function generateCommandsFile(project: Project): string { lines.push('#[allow(unused_imports)]'); lines.push('use crate::actions::{ActionEnvelope, StateAction};'); lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, ContentRef, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, Turn};'); + lines.push('use crate::state::{AgentSelection, ContentRef, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); @@ -1228,7 +1241,7 @@ const NOTIFICATION_STRUCTS = [ function generateNotificationsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, ChangesSummary, Changeset, FileEdit, ModelSelection, ProjectInfo, SessionStatus, SessionSummary};'); + lines.push('use crate::state::{AgentSelection, AnnotationsSummary, ChangesSummary, Changeset, FileEdit, ModelSelection, ProjectInfo, SessionStatus, SessionSummary};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 262013b7..5f54394b 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -106,7 +106,8 @@ function mapType(tsType: string, propName?: string, containerName?: string): str // Known unions if (tsType === 'RootState | SessionState' || tsType === 'RootState | SessionState | TerminalState' - || tsType === 'RootState | SessionState | TerminalState | ChangesetState') return 'SnapshotState'; + || tsType === 'RootState | SessionState | TerminalState | ChangesetState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState') return 'SnapshotState'; // T | null → T? const nullMatch = tsType.match(/^(.+?)\s*\|\s*null$/); @@ -273,6 +274,13 @@ function extractProps(iface: InterfaceDeclaration, project: Project): SwiftProp[ }); } +// ─── Swift Doc Emission ────────────────────────────────────────────────────── + +function emitSwiftDocLine(docLine: string, indent = ''): string { + const trimmed = docLine.trim(); + return trimmed ? `${indent}/// ${trimmed}` : `${indent}///`; +} + // ─── Swift Enum Generation ─────────────────────────────────────────────────── function generateSwiftEnum(enumDecl: EnumDeclaration): string { @@ -290,7 +298,7 @@ function generateSwiftEnum(enumDecl: EnumDeclaration): string { if (desc) { for (const docLine of desc.split('\n')) { - lines.push(`/// ${docLine.trim()}`); + lines.push(emitSwiftDocLine(docLine)); } } @@ -305,7 +313,7 @@ function generateSwiftEnum(enumDecl: EnumDeclaration): string { const memberDoc = member.getJsDocs()[0]?.getDescription().trim(); if (memberDoc) { for (const docLine of memberDoc.split('\n')) { - lines.push(` /// ${docLine.trim()}`); + lines.push(emitSwiftDocLine(docLine, ' ')); } } lines.push(` public static let ${memberName} = ${name}(rawValue: ${value})`); @@ -322,7 +330,7 @@ function generateSwiftEnum(enumDecl: EnumDeclaration): string { const memberDoc = member.getJsDocs()[0]?.getDescription().trim(); if (memberDoc) { for (const docLine of memberDoc.split('\n')) { - lines.push(` /// ${docLine.trim()}`); + lines.push(emitSwiftDocLine(docLine, ' ')); } } lines.push(` case ${memberName} = ${JSON.stringify(value)}`); @@ -358,7 +366,7 @@ function generateSwiftStruct( for (const p of props) { if (p.doc) { for (const docLine of p.doc.split('\n')) { - lines.push(` /// ${docLine.trim()}`); + lines.push(emitSwiftDocLine(docLine, ' ')); } } lines.push(` public var ${p.name}: ${p.type}`); @@ -524,6 +532,7 @@ const STATE_STRUCTS = [ 'SessionInputRequest', 'TextPosition', 'TextRange', 'TextSelection', 'SimpleMessageAttachment', 'MessageEmbeddedResourceAttachment', 'MessageResourceAttachment', + 'MessageAnnotationsAttachment', 'MarkdownResponsePart', 'ContentRef', 'ResourceReponsePart', 'ToolCallResponsePart', 'ReasoningResponsePart', 'SystemNotificationResponsePart', @@ -548,6 +557,7 @@ const STATE_STRUCTS = [ 'TerminalUnclassifiedPart', 'TerminalCommandPart', 'UsageInfo', 'ErrorInfo', 'Snapshot', 'Changeset', 'ChangesetState', 'ChangesetFile', 'ChangesetOperation', + 'AnnotationsSummary', 'AnnotationsState', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', 'ResourceWatchState', 'ResourceChange', ]; @@ -637,6 +647,7 @@ const MESSAGE_ATTACHMENT_UNION: UnionConfig = { { caseName: 'simple', structName: 'SimpleMessageAttachment', discriminantValue: 'simple' }, { caseName: 'embeddedResource', structName: 'MessageEmbeddedResourceAttachment', discriminantValue: 'embeddedResource' }, { caseName: 'resource', structName: 'MessageResourceAttachment', discriminantValue: 'resource' }, + { caseName: 'annotations', structName: 'MessageAnnotationsAttachment', discriminantValue: 'annotations' }, ], }; @@ -784,12 +795,13 @@ public enum StringOrMarkdown: Codable, Sendable, Equatable { } function generateSnapshotState(): string { - return `/// The state payload of a snapshot — root, session, terminal, or changeset state. + return `/// The state payload of a snapshot — root, session, terminal, changeset, or annotations state. public enum SnapshotState: Codable, Sendable { case root(RootState) case session(SessionState) case terminal(TerminalState) case changeset(ChangesetState) + case annotations(AnnotationsState) public init(from decoder: Decoder) throws { // SessionState has required \`summary\` field, try it first @@ -799,6 +811,8 @@ public enum SnapshotState: Codable, Sendable { self = .terminal(terminal) } else if let changeset = try? ChangesetState(from: decoder) { self = .changeset(changeset) + } else if let annotations = try? AnnotationsState(from: decoder) { + self = .annotations(annotations) } else { self = .root(try RootState(from: decoder)) } @@ -810,6 +824,7 @@ public enum SnapshotState: Codable, Sendable { case .session(let state): try state.encode(to: encoder) case .terminal(let state): try state.encode(to: encoder) case .changeset(let state): try state.encode(to: encoder) + case .annotations(let state): try state.encode(to: encoder) } } }`; @@ -933,6 +948,10 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'changeset/operationsChanged', caseName: 'changesetOperationsChanged', tsInterface: 'ChangesetOperationsChangedAction' }, { type: 'changeset/operationStatusChanged', caseName: 'changesetOperationStatusChanged', tsInterface: 'ChangesetOperationStatusChangedAction' }, { type: 'changeset/cleared', caseName: 'changesetCleared', tsInterface: 'ChangesetClearedAction' }, + { type: 'annotations/set', caseName: 'annotationsSet', tsInterface: 'AnnotationsSetAction' }, + { type: 'annotations/removed', caseName: 'annotationsRemoved', tsInterface: 'AnnotationsRemovedAction' }, + { type: 'annotations/entrySet', caseName: 'annotationsEntrySet', tsInterface: 'AnnotationsEntrySetAction' }, + { type: 'annotations/entryRemoved', caseName: 'annotationsEntryRemoved', tsInterface: 'AnnotationsEntryRemovedAction' }, { type: 'root/terminalsChanged', caseName: 'rootTerminalsChanged', tsInterface: 'RootTerminalsChangedAction' }, { type: 'root/configChanged', caseName: 'rootConfigChanged', tsInterface: 'RootConfigChangedAction' }, { type: 'terminal/data', caseName: 'terminalData', tsInterface: 'TerminalDataAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index ecefe60e..ac12bc23 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -54,6 +54,10 @@ import type { ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction, + AnnotationsSetAction, + AnnotationsRemovedAction, + AnnotationsEntrySetAction, + AnnotationsEntryRemovedAction, TerminalDataAction, TerminalInputAction, TerminalResizedAction, @@ -245,6 +249,27 @@ export type ServerChangesetAction = | ChangesetClearedAction ; +/** Union of all annotations-scoped actions. */ +export type AnnotationsAction = + | AnnotationsSetAction + | AnnotationsRemovedAction + | AnnotationsEntrySetAction + | AnnotationsEntryRemovedAction +; + +/** Union of annotations actions that clients may dispatch. */ +export type ClientAnnotationsAction = + | AnnotationsSetAction + | AnnotationsRemovedAction + | AnnotationsEntrySetAction + | AnnotationsEntryRemovedAction +; + +/** Union of annotations actions that only the server may produce. */ +export type ServerAnnotationsAction = + never +; + /** Union of all resource-watch-scoped actions. */ export type ResourceWatchAction = | ResourceWatchChangedAction @@ -318,6 +343,10 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChangesetOperationsChanged]: false, [ActionType.ChangesetOperationStatusChanged]: false, [ActionType.ChangesetCleared]: false, + [ActionType.AnnotationsSet]: true, + [ActionType.AnnotationsRemoved]: true, + [ActionType.AnnotationsEntrySet]: true, + [ActionType.AnnotationsEntryRemoved]: true, [ActionType.TerminalData]: false, [ActionType.TerminalInput]: true, [ActionType.TerminalResized]: true, diff --git a/types/actions.ts b/types/actions.ts index 67979c04..66498160 100644 --- a/types/actions.ts +++ b/types/actions.ts @@ -12,4 +12,5 @@ export * from './channels-root/actions.js'; export * from './channels-session/actions.js'; export * from './channels-terminal/actions.js'; export * from './channels-changeset/actions.js'; +export * from './channels-annotations/actions.js'; export * from './channels-resource-watch/actions.js'; diff --git a/types/channels-annotations/actions.ts b/types/channels-annotations/actions.ts new file mode 100644 index 00000000..8a958df9 --- /dev/null +++ b/types/channels-annotations/actions.ts @@ -0,0 +1,101 @@ +/** + * Annotations Channel Actions — Mutations of an `ahp-session://annotations` + * channel's state. + * + * Every annotations action is client-dispatchable: rather than issuing + * imperative RPC commands, clients drive mutations by dispatching these + * actions directly — assigning the {@link Annotation.id} / + * {@link AnnotationEntry.id} themselves, applying the action optimistically + * through the write-ahead reducer, and letting the server echo it back on the + * normal `action` envelope stream. The server MAY also originate them (e.g. an + * agent leaving an annotation of its own). Mirrors the shape of the + * `changeset/*` action family. + * + * @module channels-annotations/actions + */ + +import { ActionType } from '../common/actions.js'; +import type { AnnotationEntry, Annotation } from './state.js'; + +// ─── Annotations Actions ───────────────────────────────────────────────────── + +/** + * Upsert an {@link Annotation} in the annotations channel — adds a new + * annotation, or replaces an existing one identified by + * {@link Annotation.id}. + * + * Dispatched by a client to create an annotation (together with its + * mandatory first entry) or to re-anchor / resolve an existing one; the + * dispatching client assigns the {@link Annotation.id} and the id of any + * new entry. When replacing, the full annotation payload (including its + * {@link Annotation.entries | entries} list) is substituted; producers + * SHOULD prefer {@link AnnotationsEntrySetAction} for per-entry edits to + * keep wire updates small. + * + * @category Annotations Actions + * @version 3 + * @clientDispatchable + */ +export interface AnnotationsSetAction { + type: ActionType.AnnotationsSet; + /** The new or replacement annotation. MUST contain at least one entry. */ + annotation: Annotation; +} + +/** + * Remove an {@link Annotation} from the channel by its id. + * + * Dispatched to delete an entire annotation and every entry it contains. + * Because the protocol forbids empty annotations, a client that wants to + * remove the last remaining entry dispatches this action — collapsing the + * annotation — rather than {@link AnnotationsEntryRemovedAction}. + * + * @category Annotations Actions + * @version 3 + * @clientDispatchable + */ +export interface AnnotationsRemovedAction { + type: ActionType.AnnotationsRemoved; + /** The {@link Annotation.id} of the annotation to remove. */ + annotationId: string; +} + +/** + * Upsert an {@link AnnotationEntry} within an existing annotation — adds a + * new entry, or replaces one identified by {@link AnnotationEntry.id}. The + * dispatching client assigns the {@link AnnotationEntry.id} of a new entry. + * If {@link annotationId} does not match any current annotation the action + * is a no-op. + * + * @category Annotations Actions + * @version 3 + * @clientDispatchable + */ +export interface AnnotationsEntrySetAction { + type: ActionType.AnnotationsEntrySet; + /** The {@link Annotation.id} the entry belongs to. */ + annotationId: string; + /** The new or replacement entry. */ + entry: AnnotationEntry; +} + +/** + * Remove a single {@link AnnotationEntry} from an annotation without + * collapsing the annotation itself. Used when more than one entry remains — + * to remove the last entry a client dispatches {@link AnnotationsRemovedAction} + * instead, since the protocol forbids empty annotations. + * + * If either {@link annotationId} or {@link entryId} does not match the + * current state the action is a no-op. + * + * @category Annotations Actions + * @version 3 + * @clientDispatchable + */ +export interface AnnotationsEntryRemovedAction { + type: ActionType.AnnotationsEntryRemoved; + /** The {@link Annotation.id} the entry belongs to. */ + annotationId: string; + /** The {@link AnnotationEntry.id} to remove. */ + entryId: string; +} diff --git a/types/channels-annotations/reducer.ts b/types/channels-annotations/reducer.ts new file mode 100644 index 00000000..f39f96b7 --- /dev/null +++ b/types/channels-annotations/reducer.ts @@ -0,0 +1,89 @@ +/** + * Annotations Channel Reducer — Pure reducer for `AnnotationsState`. + * + * @module channels-annotations/reducer + */ + +import { ActionType } from '../common/actions.js'; +import type { AnnotationEntry, Annotation, AnnotationsState } from './state.js'; +import type { AnnotationsAction } from '../action-origin.generated.js'; +import { softAssertNever } from '../common/reducer-helpers.js'; + +/** + * Pure reducer for annotations state. Handles every {@link AnnotationsAction} + * variant. + * + * Per the spec, every annotations action is client-dispatchable; the reducer + * runs identically on the client (optimistic, write-ahead) and the server. It + * preserves the dispatch order of annotations (and of entries within an + * annotation): new entries are appended; `*Set` actions with a matching id + * replace in place, while actions whose target id is unknown are no-ops + * (mirroring `changeset/fileRemoved` semantics). The single-entry + * minimum invariant is enforced by producers, not the reducer — removing an + * annotation's last entry via {@link AnnotationsEntryRemovedAction} (instead + * of {@link AnnotationsRemovedAction}) would leave an empty annotation, + * which is observable but not catastrophic. + */ +export function annotationsReducer(state: AnnotationsState, action: AnnotationsAction, log?: (msg: string) => void): AnnotationsState { + switch (action.type) { + case ActionType.AnnotationsSet: { + const idx = state.annotations.findIndex(t => t.id === action.annotation.id); + if (idx < 0) { + return { ...state, annotations: [...state.annotations, action.annotation] }; + } + const next: Annotation[] = [...state.annotations]; + next[idx] = action.annotation; + return { ...state, annotations: next }; + } + + case ActionType.AnnotationsRemoved: { + const idx = state.annotations.findIndex(t => t.id === action.annotationId); + if (idx < 0) { + return state; + } + const next: Annotation[] = [...state.annotations]; + next.splice(idx, 1); + return { ...state, annotations: next }; + } + + case ActionType.AnnotationsEntrySet: { + const tIdx = state.annotations.findIndex(t => t.id === action.annotationId); + if (tIdx < 0) { + return state; + } + const annotation = state.annotations[tIdx]; + const cIdx = annotation.entries.findIndex(c => c.id === action.entry.id); + let nextEntries: AnnotationEntry[]; + if (cIdx < 0) { + nextEntries = [...annotation.entries, action.entry]; + } else { + nextEntries = [...annotation.entries]; + nextEntries[cIdx] = action.entry; + } + const nextAnnotations: Annotation[] = [...state.annotations]; + nextAnnotations[tIdx] = { ...annotation, entries: nextEntries }; + return { ...state, annotations: nextAnnotations }; + } + + case ActionType.AnnotationsEntryRemoved: { + const tIdx = state.annotations.findIndex(t => t.id === action.annotationId); + if (tIdx < 0) { + return state; + } + const annotation = state.annotations[tIdx]; + const cIdx = annotation.entries.findIndex(c => c.id === action.entryId); + if (cIdx < 0) { + return state; + } + const nextEntries: AnnotationEntry[] = [...annotation.entries]; + nextEntries.splice(cIdx, 1); + const nextAnnotations: Annotation[] = [...state.annotations]; + nextAnnotations[tIdx] = { ...annotation, entries: nextEntries }; + return { ...state, annotations: nextAnnotations }; + } + + default: + softAssertNever(action, log); + return state; + } +} diff --git a/types/channels-annotations/state.ts b/types/channels-annotations/state.ts new file mode 100644 index 00000000..2fbeb699 --- /dev/null +++ b/types/channels-annotations/state.ts @@ -0,0 +1,132 @@ +/** + * Annotations Channel State Types — Per-session inline file-annotation state + * exposed on the `ahp-session://annotations` channel. + * + * Each session owns at most one annotations channel. The channel URI is + * derived from the session URI by appending `/annotations` and is also + * surfaced explicitly on {@link AnnotationsSummary.resource} for badge UI. + * + * @module channels-annotations/state + */ + +import type { URI, StringOrMarkdown, TextRange } from '../common/state.js'; + +// ─── Annotations Summary ───────────────────────────────────────────────────── + +/** + * Lightweight per-session summary of the annotations channel, surfaced on + * {@link SessionSummary.annotations} so badge UI can render annotation / + * entry counts without subscribing to the channel itself. + * + * @category Annotations + */ +export interface AnnotationsSummary { + /** + * The subscribable annotations channel URI for the owning session + * (typically `ahp-session://annotations`). Surfaced explicitly even + * though it is derivable from the session URI so badge UI does not need + * to know the derivation rule. + */ + resource: URI; + /** Total number of {@link Annotation} entries in the channel. */ + annotationCount: number; + /** Total number of {@link AnnotationEntry} entries across every annotation. */ + entryCount: number; +} + +// ─── Annotations State ─────────────────────────────────────────────────────── + +/** + * Full state for a session's annotations channel, returned when a client + * subscribes to an `ahp-session://annotations` URI. + * + * @category Annotations + */ +export interface AnnotationsState { + /** Annotations in this channel, keyed by {@link Annotation.id}. */ + annotations: Annotation[]; +} + +// ─── Annotation ────────────────────────────────────────────────────────────── + +/** + * A conversation anchored to a specific file produced by a specific turn, + * optionally narrowed to a range within that file. + * + * {@link turnId} anchors the annotation to the file versions that turn + * produced, so a later turn that rewrites the same file does not silently + * invalidate the annotation's anchor — clients can resolve {@link resource} + * and {@link range} against the turn's changeset. When {@link range} is + * omitted the annotation is anchored to the entire file. + * + * Every annotation MUST contain at least one {@link AnnotationEntry}. An + * {@link AnnotationsSetAction} that creates an annotation therefore carries + * its mandatory first entry, and removing the last remaining entry collapses + * the annotation via {@link AnnotationsRemovedAction} rather than leaving an + * empty annotation behind. + * + * @category Annotations + */ +export interface Annotation { + /** + * Stable identifier within the annotations channel. Assigned by the client + * that dispatches the creating {@link AnnotationsSetAction}. + */ + id: string; + /** + * Turn that produced the file versions this annotation is anchored to. + * Matches a {@link Turn.id} on the owning session. + */ + turnId: string; + /** The file the annotation is anchored to. */ + resource: URI; + /** + * Range within {@link resource} the annotation is anchored to. When + * omitted the annotation is anchored to the entire file. + */ + range?: TextRange; + /** + * Whether the annotation has been resolved. Newly created annotations are + * always unresolved (`false`); a client marks an annotation resolved (or + * re-opens it) by dispatching an {@link AnnotationsSetAction} carrying the + * updated flag. + */ + resolved: boolean; + /** + * Entries in this annotation, in dispatch order (oldest first). MUST + * contain at least one entry. + */ + entries: AnnotationEntry[]; + /** + * Producer-defined opaque metadata, surfaced to tooling but not + * interpreted by the protocol. + */ + _meta?: Record; +} + +// ─── Annotation Entry ──────────────────────────────────────────────────────── + +/** + * A single entry within an {@link Annotation}. + * + * @category Annotations + */ +export interface AnnotationEntry { + /** + * Stable identifier within the enclosing annotation. Assigned by the client + * that dispatches the {@link AnnotationsEntrySetAction} (or the enclosing + * {@link AnnotationsSetAction}) introducing the entry. + */ + id: string; + /** + * Entry body. A bare `string` is rendered as plain text; pass + * `{ markdown: "…" }` to opt into Markdown rendering. See + * {@link StringOrMarkdown}. + */ + text: StringOrMarkdown; + /** + * Producer-defined opaque metadata, surfaced to tooling but not + * interpreted by the protocol. + */ + _meta?: Record; +} diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index 5a694348..dd38b953 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -6,6 +6,7 @@ */ import type { Changeset } from '../channels-changeset/state.js'; +import type { AnnotationsSummary } from '../channels-annotations/state.js'; import type { ModelSelection } from '../channels-root/state.js'; import type { ConfigPropertySchema, @@ -232,6 +233,13 @@ export interface SessionSummary { * client to subscribe to a changeset. */ changes?: ChangesSummary; + /** + * Lightweight summary of this session's inline annotations channel + * (`ahp-session://annotations`). Surfaced so badge UI can render + * annotation / entry counts without subscribing. Absent when the session + * does not expose an annotations channel. + */ + annotations?: AnnotationsSummary; } /** @@ -585,6 +593,8 @@ export const enum MessageAttachmentKind { EmbeddedResource = 'embeddedResource', /** An attachment that references a resource by URI. */ Resource = 'resource', + /** An attachment that references annotations on an annotations channel. */ + Annotations = 'annotations', } /** @@ -774,6 +784,31 @@ export interface MessageResourceAttachment extends MessageAttachmentBase, Conten selection?: TextSelection; } +/** + * An attachment that references annotations on a session's annotations + * channel (see {@link AnnotationsState}). + * + * When {@link annotationIds} is omitted the attachment references every + * annotation on the channel; when present it references only the listed + * {@link Annotation.id | annotation ids}. + * + * @category Turn Types + */ +export interface MessageAnnotationsAttachment extends MessageAttachmentBase { + /** Discriminant */ + type: MessageAttachmentKind.Annotations; + /** + * The annotations channel URI (typically `ahp-session://annotations`). + * Matches {@link AnnotationsSummary.resource}. + */ + resource: URI; + /** + * Specific {@link Annotation.id | annotation ids} to reference. When + * omitted, the attachment references all annotations on the channel. + */ + annotationIds?: string[]; +} + /** * An attachment associated with a {@link Message}. * @@ -782,7 +817,8 @@ export interface MessageResourceAttachment extends MessageAttachmentBase, Conten export type MessageAttachment = | SimpleMessageAttachment | MessageEmbeddedResourceAttachment - | MessageResourceAttachment; + | MessageResourceAttachment + | MessageAnnotationsAttachment; // ─── Response Parts ────────────────────────────────────────────────────────── diff --git a/types/common/actions.ts b/types/common/actions.ts index 3d1a4072..934d38c0 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -68,6 +68,13 @@ import type { ChangesetClearedAction, } from '../channels-changeset/actions.js'; +import type { + AnnotationsSetAction, + AnnotationsRemovedAction, + AnnotationsEntrySetAction, + AnnotationsEntryRemovedAction, +} from '../channels-annotations/actions.js'; + import type { TerminalDataAction, TerminalInputAction, @@ -143,6 +150,10 @@ export const enum ActionType { ChangesetOperationsChanged = 'changeset/operationsChanged', ChangesetOperationStatusChanged = 'changeset/operationStatusChanged', ChangesetCleared = 'changeset/cleared', + AnnotationsSet = 'annotations/set', + AnnotationsRemoved = 'annotations/removed', + AnnotationsEntrySet = 'annotations/entrySet', + AnnotationsEntryRemoved = 'annotations/entryRemoved', RootTerminalsChanged = 'root/terminalsChanged', RootConfigChanged = 'root/configChanged', TerminalData = 'terminal/data', @@ -244,6 +255,10 @@ export type StateAction = | ChangesetOperationsChangedAction | ChangesetOperationStatusChangedAction | ChangesetClearedAction + | AnnotationsSetAction + | AnnotationsRemovedAction + | AnnotationsEntrySetAction + | AnnotationsEntryRemovedAction | TerminalDataAction | TerminalInputAction | TerminalResizedAction diff --git a/types/common/reducer-helpers.ts b/types/common/reducer-helpers.ts index bf5ab48c..7742f2af 100644 --- a/types/common/reducer-helpers.ts +++ b/types/common/reducer-helpers.ts @@ -14,6 +14,8 @@ import type { ClientTerminalAction, ChangesetAction, ClientChangesetAction, + AnnotationsAction, + ClientAnnotationsAction, } from '../action-origin.generated.js'; import { IS_CLIENT_DISPATCHABLE } from '../action-origin.generated.js'; @@ -38,6 +40,6 @@ export function softAssertNever(value: never, log?: (msg: string) => void): void * Servers SHOULD call this to validate incoming `dispatchAction` requests * and reject any action the client is not allowed to originate. */ -export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction { +export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/types/common/state.ts b/types/common/state.ts index f1f7ff59..be87a53f 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -11,6 +11,7 @@ import type { RootState } from '../channels-root/state.js'; import type { SessionState } from '../channels-session/state.js'; import type { TerminalState } from '../channels-terminal/state.js'; import type { ChangesetState } from '../channels-changeset/state.js'; +import type { AnnotationsState } from '../channels-annotations/state.js'; // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -323,7 +324,7 @@ export interface Snapshot { /** The subscribed channel URI (e.g. `ahp-root://` or `ahp-session:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState; + state: RootState | SessionState | TerminalState | ChangesetState | AnnotationsState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/types/index.ts b/types/index.ts index 4b7fb65a..5a250428 100644 --- a/types/index.ts +++ b/types/index.ts @@ -34,6 +34,7 @@ export type { SimpleMessageAttachment, MessageEmbeddedResourceAttachment, MessageResourceAttachment, + MessageAnnotationsAttachment, MarkdownResponsePart, ContentRef, ToolCallResponsePart, @@ -93,6 +94,10 @@ export type { ChangesetState, ChangesetFile, ChangesetOperation, + AnnotationsSummary, + AnnotationsState, + Annotation, + AnnotationEntry, TelemetryCapabilities, ResourceWatchState, ResourceChange, @@ -170,6 +175,10 @@ export type { ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction, + AnnotationsSetAction, + AnnotationsRemovedAction, + AnnotationsEntrySetAction, + AnnotationsEntryRemovedAction, StateAction, RootTerminalsChangedAction, RootConfigChangedAction, @@ -201,6 +210,9 @@ export type { ChangesetAction, ClientChangesetAction, ServerChangesetAction, + AnnotationsAction, + ClientAnnotationsAction, + ServerAnnotationsAction, ResourceWatchAction, ClientResourceWatchAction, ServerResourceWatchAction, @@ -214,6 +226,7 @@ export { sessionReducer, terminalReducer, changesetReducer, + annotationsReducer, resourceWatchReducer, isClientDispatchable, } from './reducers.js'; diff --git a/types/messages.test.ts b/types/messages.test.ts index 9c43db72..264a1ecb 100644 --- a/types/messages.test.ts +++ b/types/messages.test.ts @@ -30,6 +30,7 @@ function readChannelSources(baseName: string): string { 'channels-session', 'channels-terminal', 'channels-changeset', + 'channels-annotations', 'channels-resource-watch', ]; return dirs diff --git a/types/reducers.test.ts b/types/reducers.test.ts index 00c50f1b..4a99a3dd 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -22,26 +22,22 @@ import { sessionReducer, terminalReducer, changesetReducer, + annotationsReducer, resourceWatchReducer, isClientDispatchable, } from './reducers.js'; import { IS_CLIENT_DISPATCHABLE } from './action-origin.generated.js'; import { ActionType } from './actions.js'; -import type { RootState, SessionState, ChangesetState, ResourceWatchState } from './state.js'; +import type { RootState, SessionState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState } from './state.js'; import { SessionLifecycle, SessionStatus, TurnState, - MessageKind + MessageKind, } from './state.js'; -import type { TerminalState } from './state.js'; const root = resolve(dirname(fileURLToPath(import.meta.url))); -function readSource(file: string): string { - return readFileSync(resolve(root, file), 'utf-8'); -} - /** * Reads and concatenates every canonical per-channel source file matching * `baseName` (e.g. `actions.ts`) under `types/common/` and @@ -55,6 +51,7 @@ function readChannelSources(baseName: string): string { 'channels-session', 'channels-terminal', 'channels-changeset', + 'channels-annotations', 'channels-resource-watch', ]; return dirs @@ -71,12 +68,14 @@ function readChannelSources(baseName: string): string { // ─── Fixture Loading ───────────────────────────────────────────────────────── +type FixtureState = RootState | SessionState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState; + interface Fixture { description: string; - reducer: 'root' | 'session' | 'terminal' | 'changeset' | 'resourceWatch'; - initial: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState; + reducer: 'root' | 'session' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch'; + initial: FixtureState; actions: unknown[]; - expected: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState; + expected: FixtureState; } /** @@ -134,6 +133,8 @@ describe('reducer fixtures', () => { state = terminalReducer(state as TerminalState, action as any); } else if (fixture.reducer === 'changeset') { state = changesetReducer(state as ChangesetState, action as any); + } else if (fixture.reducer === 'annotations') { + state = annotationsReducer(state as AnnotationsState, action as any); } else if (fixture.reducer === 'resourceWatch') { state = resourceWatchReducer(state as ResourceWatchState, action as any); } else { diff --git a/types/reducers.ts b/types/reducers.ts index b56a7e94..eb2d615a 100644 --- a/types/reducers.ts +++ b/types/reducers.ts @@ -9,5 +9,6 @@ export { rootReducer } from './channels-root/reducer.js'; export { sessionReducer } from './channels-session/reducer.js'; export { terminalReducer } from './channels-terminal/reducer.js'; export { changesetReducer } from './channels-changeset/reducer.js'; +export { annotationsReducer } from './channels-annotations/reducer.js'; export { resourceWatchReducer } from './channels-resource-watch/reducer.js'; export { softAssertNever, isClientDispatchable } from './common/reducer-helpers.js'; diff --git a/types/state.ts b/types/state.ts index 325d197d..03de20b7 100644 --- a/types/state.ts +++ b/types/state.ts @@ -12,5 +12,6 @@ export * from './channels-root/state.js'; export * from './channels-session/state.js'; export * from './channels-terminal/state.js'; export * from './channels-changeset/state.js'; +export * from './channels-annotations/state.js'; export * from './channels-otlp/state.js'; export * from './channels-resource-watch/state.js'; diff --git a/types/test-cases/reducers/210-annotations-set-appends-new-annotation.json b/types/test-cases/reducers/210-annotations-set-appends-new-annotation.json new file mode 100644 index 00000000..8fc9db8c --- /dev/null +++ b/types/test-cases/reducers/210-annotations-set-appends-new-annotation.json @@ -0,0 +1,73 @@ +{ + "description": "annotations/set appends a new annotation when its id is unknown", + "reducer": "annotations", + "initial": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { + "id": "c-1", + "text": "first annotation, first entry" + } + ] + } + ] + }, + "actions": [ + { + "type": "annotations/set", + "annotation": { + "id": "t-2", + "turnId": "turn-2", + "resource": "file:///src/b.ts", + "resolved": false, + "entries": [ + { + "id": "c-2", + "text": "second annotation, first entry" + } + ] + } + } + ], + "expected": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { + "id": "c-1", + "text": "first annotation, first entry" + } + ] + }, + { + "id": "t-2", + "turnId": "turn-2", + "resource": "file:///src/b.ts", + "resolved": false, + "entries": [ + { + "id": "c-2", + "text": "second annotation, first entry" + } + ] + } + ] + } +} diff --git a/types/test-cases/reducers/211-annotations-set-replaces-existing-annotation.json b/types/test-cases/reducers/211-annotations-set-replaces-existing-annotation.json new file mode 100644 index 00000000..98c01a53 --- /dev/null +++ b/types/test-cases/reducers/211-annotations-set-replaces-existing-annotation.json @@ -0,0 +1,50 @@ +{ + "description": "annotations/set replaces an existing annotation when the id matches, dropping the range to anchor to the whole file and marking it resolved", + "reducer": "annotations", + "initial": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "original" } + ] + } + ] + }, + "actions": [ + { + "type": "annotations/set", + "annotation": { + "id": "t-1", + "turnId": "turn-2", + "resource": "file:///src/a.ts", + "resolved": true, + "entries": [ + { "id": "c-1", "text": "rewritten" }, + { "id": "c-2", "text": "reply" } + ] + } + } + ], + "expected": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-2", + "resource": "file:///src/a.ts", + "resolved": true, + "entries": [ + { "id": "c-1", "text": "rewritten" }, + { "id": "c-2", "text": "reply" } + ] + } + ] + } +} diff --git a/types/test-cases/reducers/212-annotations-removed-drops-matching-annotation.json b/types/test-cases/reducers/212-annotations-removed-drops-matching-annotation.json new file mode 100644 index 00000000..8403f0c7 --- /dev/null +++ b/types/test-cases/reducers/212-annotations-removed-drops-matching-annotation.json @@ -0,0 +1,55 @@ +{ + "description": "annotations/removed drops the matching annotation and is a no-op for unknown ids", + "reducer": "annotations", + "initial": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "keep" } + ] + }, + { + "id": "t-2", + "turnId": "turn-1", + "resource": "file:///src/b.ts", + "range": { + "start": { "line": 2, "character": 0 }, + "end": { "line": 2, "character": 4 } + }, + "resolved": false, + "entries": [ + { "id": "c-2", "text": "drop me" } + ] + } + ] + }, + "actions": [ + { "type": "annotations/removed", "annotationId": "t-2" }, + { "type": "annotations/removed", "annotationId": "missing" } + ], + "expected": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "keep" } + ] + } + ] + } +} diff --git a/types/test-cases/reducers/213-annotations-entryset-appends-and-replaces.json b/types/test-cases/reducers/213-annotations-entryset-appends-and-replaces.json new file mode 100644 index 00000000..6f0ff55b --- /dev/null +++ b/types/test-cases/reducers/213-annotations-entryset-appends-and-replaces.json @@ -0,0 +1,51 @@ +{ + "description": "annotations/entrySet appends or replaces an entry within an annotation", + "reducer": "annotations", + "initial": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "original" } + ] + } + ] + }, + "actions": [ + { + "type": "annotations/entrySet", + "annotationId": "t-1", + "entry": { "id": "c-2", "text": "second" } + }, + { + "type": "annotations/entrySet", + "annotationId": "t-1", + "entry": { "id": "c-1", "text": "edited" } + } + ], + "expected": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "edited" }, + { "id": "c-2", "text": "second" } + ] + } + ] + } +} diff --git a/types/test-cases/reducers/214-annotations-entryset-unknown-annotation-is-no-op.json b/types/test-cases/reducers/214-annotations-entryset-unknown-annotation-is-no-op.json new file mode 100644 index 00000000..d52f66bf --- /dev/null +++ b/types/test-cases/reducers/214-annotations-entryset-unknown-annotation-is-no-op.json @@ -0,0 +1,45 @@ +{ + "description": "annotations/entrySet is a no-op when the enclosing annotation is unknown", + "reducer": "annotations", + "initial": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "only" } + ] + } + ] + }, + "actions": [ + { + "type": "annotations/entrySet", + "annotationId": "missing", + "entry": { "id": "c-x", "text": "lost" } + } + ], + "expected": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "only" } + ] + } + ] + } +} diff --git a/types/test-cases/reducers/215-annotations-entryremoved-drops-matching-entry.json b/types/test-cases/reducers/215-annotations-entryremoved-drops-matching-entry.json new file mode 100644 index 00000000..f782f222 --- /dev/null +++ b/types/test-cases/reducers/215-annotations-entryremoved-drops-matching-entry.json @@ -0,0 +1,46 @@ +{ + "description": "annotations/entryRemoved drops the matching entry and leaves other entries untouched", + "reducer": "annotations", + "initial": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "first" }, + { "id": "c-2", "text": "second" }, + { "id": "c-3", "text": "third" } + ] + } + ] + }, + "actions": [ + { "type": "annotations/entryRemoved", "annotationId": "t-1", "entryId": "c-2" }, + { "type": "annotations/entryRemoved", "annotationId": "t-1", "entryId": "missing" }, + { "type": "annotations/entryRemoved", "annotationId": "missing", "entryId": "c-1" } + ], + "expected": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "first" }, + { "id": "c-3", "text": "third" } + ] + } + ] + } +} diff --git a/types/test-cases/reducers/217-annotations-unknown-action-type-is-no-op.json b/types/test-cases/reducers/217-annotations-unknown-action-type-is-no-op.json new file mode 100644 index 00000000..16a92663 --- /dev/null +++ b/types/test-cases/reducers/217-annotations-unknown-action-type-is-no-op.json @@ -0,0 +1,41 @@ +{ + "description": "annotations unknown action type is a no-op", + "reducer": "annotations", + "initial": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "only" } + ] + } + ] + }, + "actions": [ + { "type": "annotations/unknownActionType" } + ], + "expected": { + "annotations": [ + { + "id": "t-1", + "turnId": "turn-1", + "resource": "file:///src/a.ts", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "resolved": false, + "entries": [ + { "id": "c-1", "text": "only" } + ] + } + ] + } +} diff --git a/types/version/registry.ts b/types/version/registry.ts index c4c98cfb..a34b6f32 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -125,6 +125,10 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChangesetOperationsChanged]: '0.2.0', [ActionType.ChangesetOperationStatusChanged]: '0.3.0', [ActionType.ChangesetCleared]: '0.2.0', + [ActionType.AnnotationsSet]: '0.3.0', + [ActionType.AnnotationsRemoved]: '0.3.0', + [ActionType.AnnotationsEntrySet]: '0.3.0', + [ActionType.AnnotationsEntryRemoved]: '0.3.0', [ActionType.RootTerminalsChanged]: '0.1.0', [ActionType.RootConfigChanged]: '0.1.0', [ActionType.TerminalData]: '0.1.0',