events

package
v0.17.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 15, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package events provides event system for sprout UI architecture

Package events provides the rate-limited event payload and helper for publishing rate-limit signals to the WebUI.

Package events — shell approval event types and helpers.

Index

Constants

View Source
const (
	EventTypeQueryStarted            = "query_started"
	EventTypeQueryProgress           = "query_progress"
	EventTypeQueryCompleted          = "query_completed"
	EventTypeError                   = "error"
	EventTypeToolExecution           = "tool_execution"
	EventTypeToolStart               = "tool_start"
	EventTypeToolEnd                 = "tool_end"
	EventTypeSubagentActivity        = "subagent_activity"
	EventTypeTodoUpdate              = "todo_update"
	EventTypeFileChanged             = "file_changed"
	EventTypeWorkspacePatch          = "workspace_patch"
	EventTypeFileContentChanged      = "file_content_changed"
	EventTypeStreamChunk             = "stream_chunk"
	EventTypeMetricsUpdate           = "metrics_update"
	EventTypeValidation              = "validation"
	EventTypeSecurityApprovalRequest = "security_approval_request"
	EventTypeSecurityPromptRequest   = "security_prompt_request"
	EventTypeAskUserRequest          = "ask_user_request"
	// EventTypeEditApprovalRequest (SP-072-3) is published when the
	// per-hunk diff approval gate routes a proposed file edit through the
	// WebUI for interactive review. The payload carries the request ID,
	// file path, and the diff hunks with per-line change type so the
	// frontend can render a color-coded review panel with per-hunk
	// accept/reject toggles.
	EventTypeEditApprovalRequest = "edit_approval_request"
	// EventTypeShellApprovalRequest (SP-093-3) is published when a shell
	// command needs per-part approval. The payload carries the request ID,
	// the command, the split parts (with kind/semantic/risk), a unified-view
	// string, and the overall risk level. The WebUI renders a per-part
	// approval panel and POSTs back to /api/shell-approvals/{id}/decision.
	EventTypeShellApprovalRequest = "shell_approval_request"
	// EventTypePasswordRequest (SP-089-3) is published when a shell command
	// needs a password from the user (sudo, passwd, ssh-keygen passphrase,
	// etc.). The payload carries the request ID, the command, and the prompt
	// text detected on the child's stdout. The browser renders a password
	// input and POSTs the response to /api/password/{requestID}/respond.
	EventTypePasswordRequest = "password_request"
	// EventTypeInputRequired is published when the agent is blocked waiting
	// for human input — a security approval, an ask_user prompt, or any
	// other blocking interaction. This is a higher-level signal than the
	// specific security_approval_request / security_prompt_request / ask_user_request
	// events: it lets notification subscribers (CLI bell, browser notification)
	// listen to a single "the agent needs you" signal.
	EventTypeInputRequired = "input_required"
	EventTypeAgentMessage  = "agent_message"
	// EventTypeProviderNoCredential is published when a provider change
	// would activate a provider that requires an API key but doesn't
	// have one configured. The frontend surfaces it as a sticky toast
	// pointing at Settings → Credentials, distinct from generic warning
	// messages that get inlined into the active assistant bubble.
	EventTypeProviderNoCredential = "provider_no_credential"
	EventTypeWorkspaceChanged     = "workspace_changed"
	EventTypeSessionTerminated    = "session_terminated"
	EventTypeDriftDetected        = "drift_detected"
	// EventTypeSessionChanged signals that a chat session's metadata
	// (name, pin state, active state) changed and tabs viewing that chat
	// should reconcile. SP-034-3e.
	EventTypeSessionChanged = "session_changed"
	// EventTypeDelegateClarificationRequested is published when a delegate
	// agent requests clarification from its parent agent.
	EventTypeDelegateClarificationRequested = "delegate_clarification_requested"
	// EventTypeDelegateClarificationResponded is published when a parent
	// agent responds to a delegate's clarification request.
	EventTypeDelegateClarificationResponded = "delegate_clarification_responded"
	// EventTypeCompactStarted fires immediately before a compaction
	// operation begins, whether triggered manually by /compact or
	// automatically by seed's structural compaction / context-limit
	// recovery. The payload's `source` field distinguishes the path.
	EventTypeCompactStarted = "compact_started"
	// EventTypeCompactCompleted fires after the compaction finishes,
	// successful or not. Subscribers (e.g. the auto-transcript snapshot
	// capture) use this to record the post-compact state.
	EventTypeCompactCompleted = "compact_completed"
	// EventTypeContextManagementDiagnostic (SP-066 Phase 1) reports the
	// effective context budget at each iteration so we can verify
	// substitution does the heavy lifting and the LLM fall-through
	// stays near zero.
	EventTypeContextManagementDiagnostic = "context_management_diagnostic"
	// EventTypeRecallDiagnostic (SP-066 Phase 3) reports the per-turn
	// semantic-recall pass: how long the embed took, how many candidates
	// were considered, top scores, and how many items were injected.
	// Subscribers (WebUI metrics panel, eval pipelines) use it to verify
	// recall is surfacing useful matches and to tune the half-life and
	// similarity threshold from real data.
	EventTypeRecallDiagnostic = "recall_diagnostic"
	// SP-065 Phase 2: Automate session lifecycle events
	EventTypeAutomateSessionStarted = "automate.session_started"
	EventTypeAutomateBudgetUpdate   = "automate.budget_update"
	EventTypeAutomateOutputChunk    = "automate.output_chunk"
	EventTypeAutomateSessionEnded   = "automate.session_ended"
	// EventTypeSSHTunnelStatus signals that an SSH workspace tunnel has
	// changed state — disconnected, reconnecting, or reconnected. Clients
	// use this to show a banner or retry failed requests instead of
	// surfacing raw 502 errors during the reconnect window.
	EventTypeSSHTunnelStatus = "ssh_tunnel_status"
	// EventTypeWorkspaceConflict (SP-046-3) is published when a container
	// patch conflicts with unsynced browser edits. The container writes its
	// version as <path>.theirs instead of overwriting. The payload carries
	// path, theirs_path, hash_container, hash_browser, and modified_at.
	EventTypeWorkspaceConflict = "workspace.conflict_detected"
	// EventTypeWorkspaceHeartbeatLost (SP-046-4) is published when a session's
	// heartbeat has been missed for >60s, indicating the browser tab may have
	// been closed or the connection lost. The container will terminate the
	// running job after this event. The payload carries session_id and
	// last_heartbeat (time.RFC3339).
	EventTypeWorkspaceHeartbeatLost = "workspace.heartbeat_lost"
	// EventTypeWorkspaceSessionMoved (SP-046-5) is published when a user
	// takes over a session on a new device, causing the previous device's
	// WebSocket to be closed. The payload carries session_id and new_device_id.
	// The displaced browser surfaces "This session moved to another device."
	EventTypeWorkspaceSessionMoved = "workspace.session_moved"
	// EventTypeRateLimited is published when a tool or API call hits a
	// rate-limit response from the provider. The payload is a
	// *RateLimitedEvent. WebUI consumes this to show "rate-limited,
	// retrying…" and gate the input.
	EventTypeRateLimited = "rate_limited"
	// EventTypeOOMWatchdogAlert is published by the OOM watchdog when node
	// process count or total RSS exceeds configured thresholds. The payload
	// carries the current counts, thresholds, and which threshold(s) triggered.
	EventTypeOOMWatchdogAlert = "oom_watchdog_alert"
)

Common event types

Variables

This section is empty.

Functions

func AgentMessageEvent

func AgentMessageEvent(category, message string, extra map[string]interface{}) map[string]interface{}

AgentMessageEvent creates an agent system message event. category: "info", "warning", "error", "tool_log", "thought"

func AskUserRequestEvent

func AskUserRequestEvent(requestID string, req AskUserRequest, clientID string) map[string]interface{}

AskUserRequestEvent creates an ask_user request event for the webui. Accepts any struct whose JSON shape matches AskUserRequest (the agent_tools package supplies one). Falls through fields onto the flat event payload so existing frontend consumers that only read "question" continue to work.

func AutomateBudgetUpdateEvent added in v0.16.4

func AutomateBudgetUpdateEvent(sessionID string, spentUSD, budgetUSD float64, fraction float64, iteration int) map[string]interface{}

AutomateBudgetUpdateEvent creates a budget_update event payload.

func AutomateOutputChunkEvent added in v0.16.4

func AutomateOutputChunkEvent(sessionID string, offset int, chunk string) map[string]interface{}

AutomateOutputChunkEvent creates an output_chunk event payload. Note: we send chunk_len instead of the full chunk to avoid bloating WS frames.

func AutomateSessionEndedEvent added in v0.16.4

func AutomateSessionEndedEvent(sessionID, workflow, status string, totalCost float64) map[string]interface{}

AutomateSessionEndedEvent creates a session_ended event payload.

func AutomateSessionStartedEvent added in v0.16.4

func AutomateSessionStartedEvent(sessionID, workflow, kind string) map[string]interface{}

AutomateSessionStartedEvent creates a session_started event payload.

func BuildShellApprovalUnifiedView added in v0.16.19

func BuildShellApprovalUnifiedView(parts []ShellApprovalPartArg) string

BuildShellApprovalUnifiedView creates a human-readable unified view string from the shell parts, suitable for display in the approval panel.

func CompactCompletedEvent added in v0.16.4

func CompactCompletedEvent(source string, beforeCount, afterCount int, summaryChars int, err error) map[string]interface{}

CompactCompletedEvent creates the payload for a compact_completed event. On success, err should be nil and after/summary fields describe the new state. On failure, err carries the reason and counts reflect the unchanged pre-compact totals.

func CompactStartedEvent added in v0.16.4

func CompactStartedEvent(source string, messageCount, checkpointCount int) map[string]interface{}

CompactStartedEvent creates the payload for a compact_started event. source is one of "manual" (slash command) or "auto_llm_summary" (seed structural compaction / context-limit recovery). messageCount and checkpointCount capture the pre-compact state for diagnostics.

func ContextManagementDiagnosticEvent added in v0.16.4

func ContextManagementDiagnosticEvent(currentTokens, maxTokens int, triggerFraction, reservedResponse, reservedThinking, reservedToolIO float64, iteration, messageCount int, cachedTokens, promptTokens, cacheWriteTokens int) map[string]interface{}

ContextManagementDiagnosticEvent (SP-066 Phase 1) reports the model-aware context-budget math at a single iteration. Subscribers (WebUI metrics panel, telemetry pipelines) use it to verify substitution is doing the heavy lifting and the LLM fall-through stays approximately never.

Fields:

  • current_tokens: tokenizer-estimated size of the prompt going to the model.
  • max_tokens: model's hard context-window limit.
  • effective_max: max_tokens minus reservation budget; substitution triggers when current_tokens exceeds trigger_fraction × max_tokens.
  • trigger_fraction: share of max_tokens at which seed triggers compaction (1 − total_reserved_fraction).
  • reserved_response / reserved_thinking / reserved_tool_io: the three reservation slices as fractions of max_tokens.
  • iteration: current iteration number from seed's OnIteration callback.
  • message_count: messages in the prepared prompt list.
  • cached_tokens: cumulative prompt tokens served from the provider's prompt cache so far this session.
  • prompt_tokens: cumulative prompt tokens charged so far this session.
  • cache_write_tokens: cumulative tokens written to the provider's cache (Anthropic cache_create_input_tokens). May be 0 if not tracked.
  • cache_hit_rate: cached_tokens / prompt_tokens, or 0 when prompt_tokens is 0. Lets the UI render cache effectiveness at a glance.

func DriftDetectedEvent

func DriftDetectedEvent(similarity float64, threshold float64, sessionID string) map[string]interface{}

DriftDetectedEvent creates a drift notification event for the WebUI

func EditApprovalRequestEvent added in v0.16.17

func EditApprovalRequestEvent(requestID, path, unifiedDiff string, hunks []map[string]interface{}) map[string]interface{}

EditApprovalRequestEvent (SP-072-3) creates an edit_approval_request event payload for the per-hunk diff approval gate. requestID uniquely identifies the approval so the WebUI can POST a decision back to /api/edits/{requestID}/decision. path is the file being edited. hunks is a JSON-serializable representation of each diff hunk with its line-level change type (context/add/remove). unifiedDiff is the raw unified-diff string for display.

func ErrorEvent

func ErrorEvent(message string, err error) map[string]interface{}

ErrorEvent creates an error event

func FileChangedEvent

func FileChangedEvent(filePath, action string, content string) map[string]interface{}

FileChangedEvent creates a file changed event.

The full file content is deliberately NOT transmitted. No consumer reads it — the WebUI's handler only uses file_path/action, and the editor refetches a file's bytes on demand (and gets disk-change notifications via the lean FileContentChangedEvent). Shipping whole-file content here made each event large, so a burst (bulk shell edits, many writes) filled the per-subscriber channel and the replay ring buffer fast — dropping file_changed events and spamming "EventBus Dropped file_changed event" logs. The `content` arg is retained for call-site compatibility but only its length is surfaced.

func FileContentChangedEvent

func FileContentChangedEvent(filePath string, modTime int64, size int64) map[string]interface{}

FileContentChangedEvent creates an event indicating a file's content on disk has changed while it was open in the editor

func InputRequiredEvent added in v0.16.12

func InputRequiredEvent(reason, requestID string) map[string]interface{}

InputRequiredEvent creates an input_required event payload. reason is a human-readable description of why input is needed (e.g., "security_approval", "ask_user", "blocking_prompt"). requestID optionally links to the specific request event.

func MetricsUpdateEvent

func MetricsUpdateEvent(totalTokens, contextTokens, maxContextTokens, iteration int, totalCost float64) map[string]interface{}

MetricsUpdateEvent creates a metrics update event

func MetricsUpdateEventWithCategory added in v0.16.19

func MetricsUpdateEventWithCategory(totalTokens, contextTokens, maxContextTokens, iteration int, totalCost float64, errorCategory string) map[string]interface{}

MetricsUpdateEventWithCategory is the SP-094-6 variant that includes the most-recent error category label so the cost/status footer can render "rate-limited, retrying…" distinct from generic provider errors. The default MetricsUpdateEvent still exists for callers that don't have an error context.

func OOMWatchdogAlertEvent added in v0.16.19

func OOMWatchdogAlertEvent(nodeCount int, totalRSSBytes uint64, thresholdNodeCount int, thresholdRSSBytes uint64, triggerReason string) map[string]interface{}

OOMWatchdogAlertEvent creates an oom_watchdog_alert event payload.

func PasswordRequestEvent added in v0.16.18

func PasswordRequestEvent(requestID, command, prompt string) map[string]interface{}

PasswordRequestEvent (SP-089-3) creates a password_request event payload. requestID uniquely identifies the request so the WebUI can POST a response to /api/password/{requestID}/respond. command is the shell command that triggered the prompt. prompt is the raw prompt text detected on the child's stdout/stderr (e.g., "[sudo] password for user:").

func ProviderNoCredentialEvent

func ProviderNoCredentialEvent(providerID, message string) map[string]interface{}

ProviderNoCredentialEvent creates an event signalling that the newly active provider requires an API key but doesn't have one configured. The frontend uses providerID to drive a toast that opens Settings → Credentials scoped to this provider.

func QueryCompletedEvent

func QueryCompletedEvent(query, response string, tokensUsed int, cost float64, duration time.Duration) map[string]interface{}

QueryCompletedEvent creates a query completed event

func QueryProgressEvent

func QueryProgressEvent(message string, iteration int, tokensUsed int) map[string]interface{}

QueryProgressEvent creates a query progress event

func QueryStartedEvent

func QueryStartedEvent(query, provider, model string) map[string]interface{}

QueryStartedEvent creates a query started event

func RecallDiagnosticEvent added in v0.16.4

func RecallDiagnosticEvent(embedDurationMS float64, candidatesConsidered, injected, injectedChars int, topScores []float32) map[string]interface{}

RecallDiagnosticEvent (SP-066 Phase 3) reports a single semantic-recall pass. embedDurationMS measures the embed call (the recall query's latency on the user's critical path). candidatesConsidered is what the store returned before recency rerank + filter. injected/injectedChars is what actually landed in the prompt supplement. topScores is the raw cosine similarities for the candidates so subscribers can spot near-miss patterns and tune the threshold.

func SecurityApprovalRequestEvent

func SecurityApprovalRequestEvent(requestID, toolName, riskLevel, reasoning string, extras map[string]string) map[string]interface{}

SecurityApprovalRequestEvent creates a security approval request event for the webui

func SecurityPromptRequestEvent

func SecurityPromptRequestEvent(requestID, prompt string, defaultResponse bool, extras map[string]string) map[string]interface{}

SecurityPromptRequestEvent creates a security prompt request event for the webui

func SecurityPromptResponseEvent

func SecurityPromptResponseEvent(requestID, response bool) map[string]interface{}

SecurityPromptResponseEvent creates a security prompt response event

func ShellApprovalRequestEvent added in v0.16.19

func ShellApprovalRequestEvent(requestID, command string, parts []ShellApprovalPartArg, unifiedView string, risk string) map[string]interface{}

ShellApprovalRequestEvent creates a shell_approval_request event payload. It mirrors the EditApprovalRequestEvent pattern: takes the agent-side data (passed as plain structs to avoid import cycles) and returns a flat map[string]interface{} for EventBus.Publish().

func StreamChunkEvent

func StreamChunkEvent(chunk string, contentType string) map[string]interface{}

StreamChunkEvent creates a stream chunk event with content type

func SubagentActivityEvent

func SubagentActivityEvent(toolCallID, toolName, phase, message string, details map[string]interface{}) map[string]interface{}

SubagentActivityEvent creates a structured subagent activity event. phase is typically "spawn", "output", or "complete".

func SubagentClarificationRequestedEvent added in v0.16.12

func SubagentClarificationRequestedEvent(subagentID, requestID, question string) map[string]interface{}

SubagentClarificationRequestedEvent creates a delegate_clarification_requested event payload.

func SubagentClarificationRespondedEvent added in v0.16.12

func SubagentClarificationRespondedEvent(subagentID, requestID, response string) map[string]interface{}

SubagentClarificationRespondedEvent creates a delegate_clarification_responded event payload.

func TodoUpdateEvent

func TodoUpdateEvent(todos []map[string]interface{}) map[string]interface{}

TodoUpdateEvent creates a todo update event

func ToolEndEvent

func ToolEndEvent(toolCallID, toolName, status, result, errorMessage string, duration time.Duration) map[string]interface{}

ToolEndEvent creates a tool end event with result and status

func ToolExecutionEvent

func ToolExecutionEvent(toolName, action string, details map[string]interface{}) map[string]interface{}

ToolExecutionEvent creates a tool execution event

func ToolStartEvent

func ToolStartEvent(toolName, toolCallID, arguments, displayName, persona string, isSubagent bool, subagentType string, toolIndex int) map[string]interface{}

ToolStartEvent creates a tool start event with rich metadata

func ValidationEvent

func ValidationEvent(filePath string, diagnostics []map[string]interface{}) map[string]interface{}

ValidationEvent creates a validation event

func WorkspaceChangedEvent

func WorkspaceChangedEvent(daemonRoot, workspaceRoot, previousWorkspaceRoot string) map[string]interface{}

WorkspaceChangedEvent creates a workspace changed event

func WorkspacePatchEvent

func WorkspacePatchEvent(filePath, content, action string, seqNum int64, conflictInfo ...PatchConflictInfo) map[string]interface{}

WorkspacePatchEvent creates a workspace_patch event payload for real-time file content synchronization from the agent to the browser. The optional conflictInfo parameter enriches the event with conflict metadata when the container patch conflicts with unsynced browser edits.

Types

type AskUserRequest added in v0.16.4

type AskUserRequest struct {
	Question    string                 `json:"question"`
	Header      string                 `json:"header,omitempty"`
	Options     []AskUserRequestOption `json:"options,omitempty"`
	MultiSelect bool                   `json:"multi_select,omitempty"`
	Default     string                 `json:"default,omitempty"`
}

AskUserRequest mirrors agent_tools.AskUserRequest in shape; declared here to avoid an import cycle (events is a leaf package). The event payload carries these fields verbatim so the WebUI can render options, header, and the multi-select / default affordances.

type AskUserRequestOption added in v0.16.4

type AskUserRequestOption struct {
	Label       string `json:"label"`
	Value       string `json:"value,omitempty"`
	Description string `json:"description,omitempty"`
}

AskUserRequestOption is a single selectable choice in an ask_user prompt.

type EventBus

type EventBus struct {
	// contains filtered or unexported fields
}

EventBus manages event distribution between CLI and Web UI

func NewEventBus

func NewEventBus() *EventBus

NewEventBus creates a new event bus

func (*EventBus) Publish

func (eb *EventBus) Publish(eventType string, data any)

Publish broadcasts an event to all subscribers. Critical events (security approvals, prompts) are never silently dropped — if the channel is full, they replace the oldest event to make room.

func (*EventBus) Subscribe

func (eb *EventBus) Subscribe(name string) <-chan UIEvent

Subscribe adds a new subscriber to the event bus

func (*EventBus) Unsubscribe

func (eb *EventBus) Unsubscribe(name string)

Unsubscribe removes a subscriber from the event bus

type PatchConflictInfo

type PatchConflictInfo struct {
	Conflict   bool
	TheirsPath string
}

PatchConflictInfo holds optional conflict metadata for a workspace_patch event.

type RateLimitedEvent added in v0.16.19

type RateLimitedEvent struct {
	Provider     string `json:"provider"`
	Attempt      int    `json:"attempt"`
	MaxAttempts  int    `json:"max_attempts"`
	RetryAfterMS int    `json:"retry_after_ms"`
	Message      string `json:"message"`
	SessionID    string `json:"session_id,omitempty"`
}

RateLimitedEvent is the payload for EventTypeRateLimited. The approval broker publishes this when a tool call returns a RateLimitError so the WebUI can show "rate-limited, retrying…" and disable the input until the backoff elapses.

func RateLimitedEventFromError added in v0.16.19

func RateLimitedEventFromError(provider string, attempt, maxAttempts, retryAfterMS int, sessionID string, err error) *RateLimitedEvent

RateLimitedEventFromError builds a RateLimitedEvent from a typed error and a backoff context. If the cause is not a rate-limit error, returns nil — caller should check before publishing.

type ShellApprovalPart added in v0.16.19

type ShellApprovalPart struct {
	ID       string `json:"id"`
	Text     string `json:"text"`
	Kind     string `json:"kind"`
	Semantic string `json:"semantic"`
	Risk     string `json:"risk"`
}

ShellApprovalPart represents a single logical segment of a shell command in the approval payload. Declared here (not in agent) to avoid an import cycle — agent imports events, so events cannot import agent.

type ShellApprovalPartArg added in v0.16.19

type ShellApprovalPartArg struct {
	ID       string
	Text     string
	Kind     string
	Semantic string
	Risk     string
}

ShellApprovalPartArg is a plain-struct mirror of agent.ShellPart so ShellApprovalRequestEvent can accept agent-side data without importing the agent package (which would create a cycle).

Callers in pkg/agent construct the parts slice from ShellProposal.Parts:

parts := make([]events.ShellApprovalPartArg, len(proposal.Parts))
for i, p := range proposal.Parts {
    parts[i] = events.ShellApprovalPartArg{
        ID: p.ID, Text: p.Text,
        Kind: string(p.Kind), Semantic: p.Semantic,
        Risk: string(proposal.RiskLevel),
    }
}

type ShellApprovalRequestPayload added in v0.16.19

type ShellApprovalRequestPayload struct {
	RequestID   string              `json:"request_id"`
	Command     string              `json:"command"`
	Parts       []ShellApprovalPart `json:"parts"`
	UnifiedView string              `json:"unified_view"`
	RiskLevel   string              `json:"risk_level"`
	Timestamp   string              `json:"timestamp"`
}

ShellApprovalRequestPayload is the JSON shape of a shell_approval_request event. The frontend consumes this to render the per-part approval panel.

type ShellApprovalResponsePayload added in v0.16.19

type ShellApprovalResponsePayload struct {
	RequestID string          `json:"request_id"`
	Decisions map[string]bool `json:"decisions"`
}

ShellApprovalResponsePayload is the JSON shape the WebUI POSTs back to /api/shell-approvals/{id}/decision. It maps each part ID to an approved boolean.

type UIEvent

type UIEvent struct {
	ID        string    `json:"id"`
	Type      string    `json:"type"`
	Timestamp time.Time `json:"timestamp"`
	Data      any       `json:"data"`
}

UIEvent represents an event that can be forwarded between CLI and Web UI.

@ts-generated webui/src/types/generated.ts::UIEvent SP-034-5b: the EventType* constants below are mirrored as the ServerEventType string-literal union in generated.ts. The outbound registry in pkg/webui/websocket_outbound_registry.go covers the same surface (a test asserts they stay in sync).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL