blob: 69e57794ac9e24c4212b048ed6cec875fadbd703 [file]
import { TOOL_ACTIVITY_KINDS, TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core';
import type { ToolResultPreviewContent } from '@maka/core';
import { decodeToolResultPreviewContent } from '@maka/core';
import type { ToolActivityKind } from '@maka/core';
import {
assertExactKeys,
requireCount,
requireEntityId,
requireExactRecord,
requireId,
requireRecord,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import {
decodeSessionInteractionProjection,
type SessionInteractionProjection,
} from './interaction.js';
import {
decodeSessionMessageQueueProjection,
type SessionMessageQueueProjection,
} from './message.js';
import { defineOperation } from './operation-spec.js';
import { decodeTurnSnapshot, type TurnSnapshot } from './turn.js';
import { decodeGoalProjection, type GoalProjection } from './goal.js';
import { decodeRuntimeResourceRef } from './runtime-resource.js';
export const SESSION_CONTINUITY_SCHEMA_VERSION = 3 as const;
export const SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES = 56 * 1024;
export const SESSION_LIVE_DELTA_MAX_BYTES = 16 * 1024;
// Core emits at most 8,192 UTF-16 code units per tool output event. A code unit
// needs at most three UTF-8 bytes (an astral pair needs four bytes total).
export const SESSION_TOOL_OUTPUT_DELTA_MAX_BYTES = 3 * TOOL_OUTPUT_DELTA_MAX_CHARS;
export const SESSION_TOOL_NAME_MAX_BYTES = 256;
export const SESSION_SUBSCRIPTION_FRAME_MAX_BYTES = 64 * 1024 - 1;
export const SESSION_RUNTIME_RESOURCE_PTY_DATA_MAX_BYTES = 48 * 1024;
export type SessionLifecycleStatus =
| 'active'
| 'running'
| 'waiting_for_user'
| 'blocked'
| 'review'
| 'done'
| 'archived'
| 'aborted';
export interface SessionContinuityIdentity {
sessionId: string;
metadataRevision: number;
status: SessionLifecycleStatus;
createdAt: number;
lastUsedAt: number;
isArchived: boolean;
archivedAt?: number;
}
export interface SessionContinuitySnapshot {
schemaVersion: typeof SESSION_CONTINUITY_SCHEMA_VERSION;
session: SessionContinuityIdentity;
projectionRevision: number;
rootTurn: TurnSnapshot | null;
goal: GoalProjection | null;
queue: SessionMessageQueueProjection;
interactions: SessionInteractionProjection;
}
export interface SubscriptionOpenInput {
sessionId: string;
}
export interface SubscriptionOpenResult {
hostEpoch: string;
subscriptionId: string;
nextSequence: number;
snapshot: SessionContinuitySnapshot;
}
export interface SubscriptionCloseInput {
subscriptionId: string;
}
export interface SubscriptionCloseResult {
subscriptionId: string;
}
interface SubscriptionEnvelope {
hostEpoch: string;
subscriptionId: string;
sequence: number;
}
export interface SessionProjectionFrame extends SubscriptionEnvelope {
kind: 'subscription.session_projection';
snapshot: SessionContinuitySnapshot;
}
export interface SessionAssistantDelta {
kind: 'text' | 'thinking';
turnId: string;
runId: string;
messageId: string;
startOffset: number;
text: string;
}
export interface SessionDeltaFrame extends SubscriptionEnvelope {
kind: 'subscription.session_delta';
sessionId: string;
delta: SessionAssistantDelta;
}
interface SessionToolEventIdentity {
id: string;
turnId: string;
ts: number;
toolUseId: string;
}
export type SessionToolEvent =
| (SessionToolEventIdentity & {
type: 'tool_start';
toolName: string;
operationId?: string;
// From the one list, not a fourth copy of it. This was written out by
// hand and drifted the moment a kind was added — the wire then rejected
// an event the rest of the system considered valid.
activityKind?: ToolActivityKind;
displayName?: string;
stepId?: string;
})
| (SessionToolEventIdentity & {
type: 'tool_output_delta';
seq: number;
stream: 'stdout' | 'stderr';
chunk: string;
redacted: boolean;
createdAt: number;
})
| (SessionToolEventIdentity & {
type: 'tool_progress';
chunk: string;
})
| (SessionToolEventIdentity & {
type: 'tool_result';
operationId?: string;
status: 'completed' | 'errored';
durationMs?: number;
})
| (SessionToolEventIdentity & {
type: 'tool_result_preview';
isError: boolean;
content: ToolResultPreviewContent;
});
export interface SessionEventFrame extends SubscriptionEnvelope {
kind: 'subscription.session_event';
sessionId: string;
runId: string;
event: SessionToolEvent;
}
export const SESSION_DOMAINS = ['task', 'plan', 'deep_research', 'runtime_resource'] as const;
export type SessionDomain = (typeof SESSION_DOMAINS)[number];
export const SESSION_RUNTIME_RESOURCE_CHANGES_MAX = 64;
export interface SessionRuntimeResourceChange {
sourceSessionId: string;
ref: string;
}
export type SessionDomainChange =
| {
sessionId: string;
domain: Exclude<SessionDomain, 'runtime_resource'>;
}
| {
sessionId: string;
domain: 'runtime_resource';
resources: readonly SessionRuntimeResourceChange[];
};
export type SessionDomainChangedFrame = SubscriptionEnvelope &
SessionDomainChange & {
kind: 'subscription.session_domain_changed';
};
export interface SessionRuntimeResourcePtyDataFrame extends SubscriptionEnvelope {
kind: 'subscription.runtime_resource_pty_data';
sessionId: string;
ref: string;
ptySequence: number;
data: string;
}
export type AgentGraphChangedReason = 'observation' | 'runtime_activity' | 'reconciled' | 'stopped';
export interface AgentGraphChangedFrame extends SubscriptionEnvelope {
kind: 'subscription.agent_graph_changed';
rootSessionId: string;
graphId: string;
reason: AgentGraphChangedReason;
}
export interface SubscriptionClosedFrame extends SubscriptionEnvelope {
kind: 'subscription.closed';
reason: 'slow_consumer' | 'session_removed';
}
export type SubscriptionFrame =
| SessionProjectionFrame
| SessionDeltaFrame
| SessionEventFrame
| SessionDomainChangedFrame
| SessionRuntimeResourcePtyDataFrame
| AgentGraphChangedFrame
| SubscriptionClosedFrame;
const SUBSCRIPTION_OPEN_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'not_found',
'operation_conflict',
'internal_failure',
] as const;
const SUBSCRIPTION_CLOSE_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'not_found',
'internal_failure',
] as const;
export const SESSION_CONTINUITY_OPERATION_SPECS = {
'subscription.open': defineOperation({
mode: 'control',
availability: 'ready',
errors: SUBSCRIPTION_OPEN_ERRORS,
decodeInput: decodeSubscriptionOpenInput,
decodeOutput: decodeSubscriptionOpenResult,
}),
'subscription.close': defineOperation({
mode: 'control',
availability: 'ready',
errors: SUBSCRIPTION_CLOSE_ERRORS,
decodeInput: decodeSubscriptionCloseInput,
decodeOutput: decodeSubscriptionCloseResult,
}),
} as const;
export function decodeSubscriptionFrame(value: unknown): SubscriptionFrame {
requireEncodedByteLimit(value, 'subscription frame', SESSION_SUBSCRIPTION_FRAME_MAX_BYTES);
const record = requireRecord(value, 'subscription frame');
const envelope = decodeEnvelope(record);
let frame: SubscriptionFrame;
if (record.kind === 'subscription.session_projection') {
assertExactKeys(record, 'Session projection frame', [
'kind',
'hostEpoch',
'subscriptionId',
'sequence',
'snapshot',
]);
const snapshot = decodeSessionContinuitySnapshot(record.snapshot);
assertQueueEpoch(snapshot, envelope.hostEpoch);
frame = { kind: record.kind, ...envelope, snapshot };
} else if (record.kind === 'subscription.session_delta') {
assertExactKeys(record, 'Session delta frame', [
'kind',
'hostEpoch',
'subscriptionId',
'sequence',
'sessionId',
'delta',
]);
frame = {
kind: record.kind,
...envelope,
sessionId: requireEntityId(record.sessionId, 'sessionId'),
delta: decodeAssistantDelta(record.delta),
};
} else if (record.kind === 'subscription.session_event') {
assertExactKeys(record, 'Session event frame', [
'kind',
'hostEpoch',
'subscriptionId',
'sequence',
'sessionId',
'runId',
'event',
]);
frame = {
kind: record.kind,
...envelope,
sessionId: requireEntityId(record.sessionId, 'sessionId'),
runId: requireEntityId(record.runId, 'runId'),
event: decodeSessionToolEvent(record.event),
};
} else if (record.kind === 'subscription.agent_graph_changed') {
assertExactKeys(record, 'Agent graph changed frame', [
'kind',
'hostEpoch',
'subscriptionId',
'sequence',
'rootSessionId',
'graphId',
'reason',
]);
frame = {
kind: record.kind,
...envelope,
rootSessionId: requireEntityId(record.rootSessionId, 'rootSessionId'),
graphId: requireEntityId(record.graphId, 'graphId'),
reason: requireAgentGraphChangedReason(record.reason),
};
} else if (record.kind === 'subscription.session_domain_changed') {
const domain = requireSessionDomain(record.domain);
assertExactKeys(record, 'Session domain changed frame', [
'kind',
'hostEpoch',
'subscriptionId',
'sequence',
'sessionId',
'domain',
...(domain === 'runtime_resource' ? ['resources'] : []),
]);
const sessionId = requireEntityId(record.sessionId, 'sessionId');
frame =
domain === 'runtime_resource'
? {
kind: record.kind,
...envelope,
sessionId,
domain,
resources: decodeSessionRuntimeResourceChanges(record.resources),
}
: { kind: record.kind, ...envelope, sessionId, domain };
} else if (record.kind === 'subscription.runtime_resource_pty_data') {
assertExactKeys(record, 'Runtime Resource PTY data frame', [
'kind',
'hostEpoch',
'subscriptionId',
'sequence',
'sessionId',
'ref',
'ptySequence',
'data',
]);
frame = {
kind: record.kind,
...envelope,
sessionId: requireEntityId(record.sessionId, 'sessionId'),
ref: decodeRuntimeResourceRef(record.ref),
ptySequence: requirePositiveCount(record.ptySequence, 'PTY sequence'),
data: requireUtf8BoundedString(
record.data,
'Runtime Resource PTY data',
SESSION_RUNTIME_RESOURCE_PTY_DATA_MAX_BYTES,
),
};
} else if (record.kind === 'subscription.closed') {
assertExactKeys(record, 'subscription closed frame', [
'kind',
'hostEpoch',
'subscriptionId',
'sequence',
'reason',
]);
if (record.reason !== 'slow_consumer' && record.reason !== 'session_removed') {
throw invalidProtocolFrame('Invalid subscription close reason');
}
frame = { kind: record.kind, ...envelope, reason: record.reason };
} else {
throw invalidProtocolFrame('Unknown subscription frame kind');
}
return frame;
}
function decodeSessionRuntimeResourceChanges(value: unknown): SessionRuntimeResourceChange[] {
if (
!Array.isArray(value) ||
value.length === 0 ||
value.length > SESSION_RUNTIME_RESOURCE_CHANGES_MAX
) {
throw invalidProtocolFrame('Invalid Session Runtime Resource changes');
}
const seen = new Set<string>();
return value.map((candidate) => {
const record = requireExactRecord(candidate, 'Session Runtime Resource change', [
'sourceSessionId',
'ref',
]);
const change = {
sourceSessionId: requireEntityId(record.sourceSessionId, 'sourceSessionId'),
ref: decodeRuntimeResourceRef(record.ref),
};
const identity = JSON.stringify([change.sourceSessionId, change.ref]);
if (seen.has(identity)) {
throw invalidProtocolFrame('Duplicate Session Runtime Resource change');
}
seen.add(identity);
return change;
});
}
export function isSubscriptionFrameKind(value: unknown): value is SubscriptionFrame['kind'] {
return (
value === 'subscription.session_projection' ||
value === 'subscription.session_delta' ||
value === 'subscription.session_event' ||
value === 'subscription.session_domain_changed' ||
value === 'subscription.runtime_resource_pty_data' ||
value === 'subscription.agent_graph_changed' ||
value === 'subscription.closed'
);
}
function requireSessionDomain(value: unknown): SessionDomain {
if (typeof value !== 'string' || !(SESSION_DOMAINS as readonly string[]).includes(value)) {
throw invalidProtocolFrame('Invalid Session domain');
}
return value as SessionDomain;
}
export function decodeSessionContinuitySnapshot(value: unknown): SessionContinuitySnapshot {
requireEncodedByteLimit(
value,
'Session continuity snapshot',
SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES,
);
const record = requireExactRecord(value, 'Session continuity snapshot', [
'schemaVersion',
'session',
'projectionRevision',
'rootTurn',
'goal',
'queue',
'interactions',
]);
if (record.schemaVersion !== SESSION_CONTINUITY_SCHEMA_VERSION) {
throw invalidProtocolFrame('Unsupported Session continuity snapshot schema');
}
const session = decodeSessionContinuityIdentity(record.session);
const rootTurn = record.rootTurn === null ? null : decodeTurnSnapshot(record.rootTurn);
if (rootTurn !== null && rootTurn.sessionId !== session.sessionId) {
throw invalidProtocolFrame('Session continuity root Turn belongs to a different Session');
}
const interactions = decodeSessionInteractionProjection(record.interactions, session.sessionId);
const goal = record.goal === null ? null : decodeGoalProjection(record.goal);
if (goal !== null && goal.sessionId !== session.sessionId) {
throw invalidProtocolFrame('Session continuity Goal belongs to a different Session');
}
return {
schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION,
session,
projectionRevision: requirePositiveCount(record.projectionRevision, 'projectionRevision'),
rootTurn,
goal,
queue: decodeSessionMessageQueueProjection(record.queue),
interactions,
};
}
function decodeSubscriptionOpenInput(value: unknown): SubscriptionOpenInput {
const record = requireExactRecord(value, 'subscription.open input', ['sessionId']);
return { sessionId: requireEntityId(record.sessionId, 'sessionId') };
}
function decodeSubscriptionOpenResult(value: unknown): SubscriptionOpenResult {
const record = requireExactRecord(value, 'subscription.open result', [
'hostEpoch',
'subscriptionId',
'nextSequence',
'snapshot',
]);
const hostEpoch = requireId(record.hostEpoch, 'hostEpoch');
const snapshot = decodeSessionContinuitySnapshot(record.snapshot);
assertQueueEpoch(snapshot, hostEpoch);
return {
hostEpoch,
subscriptionId: requireId(record.subscriptionId, 'subscriptionId'),
nextSequence: requirePositiveCount(record.nextSequence, 'nextSequence'),
snapshot,
};
}
function decodeSubscriptionCloseInput(value: unknown): SubscriptionCloseInput {
const record = requireExactRecord(value, 'subscription.close input', ['subscriptionId']);
return { subscriptionId: requireId(record.subscriptionId, 'subscriptionId') };
}
function decodeSubscriptionCloseResult(value: unknown): SubscriptionCloseResult {
const record = requireExactRecord(value, 'subscription.close result', ['subscriptionId']);
return { subscriptionId: requireId(record.subscriptionId, 'subscriptionId') };
}
function decodeEnvelope(record: Record<string, unknown>): SubscriptionEnvelope {
return {
hostEpoch: requireId(record.hostEpoch, 'hostEpoch'),
subscriptionId: requireId(record.subscriptionId, 'subscriptionId'),
sequence: requirePositiveCount(record.sequence, 'sequence'),
};
}
function decodeAssistantDelta(value: unknown): SessionAssistantDelta {
const record = requireExactRecord(value, 'Session assistant delta', [
'kind',
'turnId',
'runId',
'messageId',
'startOffset',
'text',
]);
if (record.kind !== 'text' && record.kind !== 'thinking') {
throw invalidProtocolFrame('Invalid Session assistant delta kind');
}
return {
kind: record.kind,
turnId: requireEntityId(record.turnId, 'turnId'),
runId: requireEntityId(record.runId, 'runId'),
messageId: requireEntityId(record.messageId, 'messageId'),
startOffset: requireCount(record.startOffset, 'Session assistant delta start offset'),
text: requireUtf8BoundedString(
record.text,
'Session assistant delta text',
SESSION_LIVE_DELTA_MAX_BYTES,
),
};
}
function decodeSessionToolEvent(value: unknown): SessionToolEvent {
const record = requireRecord(value, 'Session tool event');
const identity = {
id: requireId(record.id, 'Session tool event id'),
turnId: requireEntityId(record.turnId, 'turnId'),
ts: requireCount(record.ts, 'Session tool event timestamp'),
toolUseId: requireId(record.toolUseId, 'toolUseId'),
};
if (record.type === 'tool_start') {
const allowed = [
'type',
'id',
'turnId',
'ts',
'toolUseId',
'toolName',
'operationId',
'activityKind',
'displayName',
'stepId',
];
assertAllowedKeys(record, 'Session tool start event', allowed);
assertRequiredKeys(record, 'Session tool start event', [
'type',
'id',
'turnId',
'ts',
'toolUseId',
'toolName',
]);
return {
type: record.type,
...identity,
toolName: requireUtf8BoundedString(
record.toolName,
'Session tool name',
SESSION_TOOL_NAME_MAX_BYTES,
),
...(record.operationId === undefined
? {}
: { operationId: requireEntityId(record.operationId, 'operationId') }),
...(record.activityKind === undefined
? {}
: { activityKind: requireToolActivityKind(record.activityKind) }),
...(record.displayName === undefined
? {}
: {
displayName: requireUtf8BoundedString(
record.displayName,
'Session tool display name',
SESSION_TOOL_NAME_MAX_BYTES,
),
}),
...(record.stepId === undefined ? {} : { stepId: requireEntityId(record.stepId, 'stepId') }),
};
}
if (record.type === 'tool_output_delta') {
assertExactKeys(record, 'Session tool output delta event', [
'type',
'id',
'turnId',
'ts',
'toolUseId',
'seq',
'stream',
'chunk',
'redacted',
'createdAt',
]);
if (record.stream !== 'stdout' && record.stream !== 'stderr') {
throw invalidProtocolFrame('Invalid Session tool output stream');
}
if (typeof record.redacted !== 'boolean') {
throw invalidProtocolFrame('Invalid Session tool output redaction');
}
return {
type: record.type,
...identity,
seq: requireCount(record.seq, 'Session tool output sequence'),
stream: record.stream,
chunk: requireUtf8BoundedString(
record.chunk,
'Session tool output chunk',
SESSION_TOOL_OUTPUT_DELTA_MAX_BYTES,
),
redacted: record.redacted,
createdAt: requireCount(record.createdAt, 'Session tool output timestamp'),
};
}
if (record.type === 'tool_progress') {
assertExactKeys(record, 'Session tool progress event', [
'type',
'id',
'turnId',
'ts',
'toolUseId',
'chunk',
]);
return {
type: record.type,
...identity,
chunk: requireUtf8BoundedString(
record.chunk,
'Session tool progress chunk',
SESSION_LIVE_DELTA_MAX_BYTES,
),
};
}
if (record.type === 'tool_result') {
const allowed = [
'type',
'id',
'turnId',
'ts',
'toolUseId',
'operationId',
'status',
'durationMs',
];
assertAllowedKeys(record, 'Session tool result event', allowed);
assertRequiredKeys(record, 'Session tool result event', [
'type',
'id',
'turnId',
'ts',
'toolUseId',
'status',
]);
if (record.status !== 'completed' && record.status !== 'errored') {
throw invalidProtocolFrame('Invalid Session tool result status');
}
return {
type: record.type,
...identity,
...(record.operationId === undefined
? {}
: { operationId: requireEntityId(record.operationId, 'operationId') }),
status: record.status,
...(record.durationMs === undefined
? {}
: {
durationMs: requireCount(record.durationMs, 'Session tool result duration'),
}),
};
}
if (record.type === 'tool_result_preview') {
assertExactKeys(record, 'Session tool result preview event', [
'type',
'id',
'turnId',
'ts',
'toolUseId',
'isError',
'content',
]);
if (typeof record.isError !== 'boolean') {
throw invalidProtocolFrame('Invalid Session tool result preview isError');
}
let content: ToolResultPreviewContent;
try {
content = decodeToolResultPreviewContent(record.content);
} catch {
throw invalidProtocolFrame('Invalid Session tool result preview content');
}
return {
type: record.type,
...identity,
isError: record.isError,
content,
};
}
throw invalidProtocolFrame('Invalid Session tool event type');
}
function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdentity {
const record = requireRecord(value, 'Session continuity identity');
assertAllowedKeys(record, 'Session continuity identity', [
'sessionId',
'metadataRevision',
'status',
'createdAt',
'lastUsedAt',
'isArchived',
'archivedAt',
]);
assertRequiredKeys(record, 'Session continuity identity', [
'sessionId',
'metadataRevision',
'status',
'createdAt',
'lastUsedAt',
'isArchived',
]);
if (typeof record.isArchived !== 'boolean') {
throw invalidProtocolFrame('Invalid Session archived state');
}
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
metadataRevision: requirePositiveCount(record.metadataRevision, 'metadataRevision'),
status: requireSessionLifecycleStatus(record.status),
createdAt: requireCount(record.createdAt, 'createdAt'),
lastUsedAt: requireCount(record.lastUsedAt, 'lastUsedAt'),
isArchived: record.isArchived,
...(record.archivedAt === undefined
? {}
: { archivedAt: requireCount(record.archivedAt, 'archivedAt') }),
};
}
function assertQueueEpoch(snapshot: SessionContinuitySnapshot, hostEpoch: string): void {
if (snapshot.queue.hostEpoch !== hostEpoch) {
throw invalidProtocolFrame('Session queue projection belongs to a different Host Epoch');
}
}
function assertAllowedKeys(
record: Record<string, unknown>,
label: string,
keys: readonly string[],
): void {
const allowed = new Set(keys);
if (Object.keys(record).some((key) => !allowed.has(key))) {
throw invalidProtocolFrame(`Unknown ${label} field`);
}
}
function assertRequiredKeys(
record: Record<string, unknown>,
label: string,
keys: readonly string[],
): void {
if (keys.some((key) => !Object.hasOwn(record, key))) {
throw invalidProtocolFrame(`Invalid ${label} fields`);
}
}
function requirePositiveCount(value: unknown, label: string): number {
const count = requireCount(value, label);
if (count === 0) throw invalidProtocolFrame(`Invalid ${label}`);
return count;
}
function requireUtf8BoundedString(value: unknown, label: string, maxBytes: number): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
Buffer.byteLength(value, 'utf8') > maxBytes
) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value;
}
function requireEncodedByteLimit(value: unknown, label: string, maxBytes: number): void {
let encoded: string | undefined;
try {
encoded = JSON.stringify(value);
} catch {
throw invalidProtocolFrame(`Invalid ${label}`);
}
if (encoded === undefined || Buffer.byteLength(encoded, 'utf8') > maxBytes) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
}
/**
* Read against the one list rather than a copy of it.
*
* This was a hand-written chain, and a decoder is the worst place for a copy:
* a kind added anywhere else made this reject the whole frame, so the failure
* arrives as a protocol violation rather than as anything naming the field.
*/
function requireToolActivityKind(value: unknown): ToolActivityKind {
if (typeof value === 'string' && (TOOL_ACTIVITY_KINDS as readonly string[]).includes(value)) {
return value as ToolActivityKind;
}
throw invalidProtocolFrame('Invalid Session tool activity kind');
}
function requireSessionLifecycleStatus(value: unknown): SessionLifecycleStatus {
if (
value === 'active' ||
value === 'running' ||
value === 'waiting_for_user' ||
value === 'blocked' ||
value === 'review' ||
value === 'done' ||
value === 'archived' ||
value === 'aborted'
)
return value;
throw invalidProtocolFrame('Invalid Session lifecycle status');
}
function requireAgentGraphChangedReason(value: unknown): AgentGraphChangedReason {
if (
value === 'observation' ||
value === 'runtime_activity' ||
value === 'reconciled' ||
value === 'stopped'
) {
return value;
}
throw invalidProtocolFrame('Invalid Agent graph changed reason');
}