02dc03d03f
- Added per-path edit versioning in `EditTool` to drop stale late diagnostics after later edits. - Added deferred diagnostics queueing through `queueDeferredDiagnostics` and late-diagnostic yield batching. - Updated `UiHelpers` to render late diagnostic file path and summary lines in the chat transcript.
609 lines
20 KiB
TypeScript
609 lines
20 KiB
TypeScript
/**
|
|
* Custom message types and transformers for the coding agent.
|
|
*
|
|
* Extends the base AgentMessage type with coding-agent specific message types,
|
|
* and provides a transformer to convert them to LLM-compatible messages.
|
|
*/
|
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
import {
|
|
type BranchSummaryMessage,
|
|
type CompactionSummaryMessage,
|
|
renderBranchSummaryContext,
|
|
renderCompactionSummaryContext,
|
|
} from "@oh-my-pi/pi-agent-core/compaction/messages";
|
|
import type {
|
|
AssistantMessage,
|
|
ImageContent,
|
|
Message,
|
|
MessageAttribution,
|
|
TextContent,
|
|
ToolResultMessage,
|
|
UserMessage,
|
|
} from "@oh-my-pi/pi-ai";
|
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
import userInterjectionTemplate from "../prompts/steering/user-interjection.md" with { type: "text" };
|
|
|
|
export {
|
|
type BranchSummaryMessage,
|
|
type CompactionSummaryMessage,
|
|
createBranchSummaryMessage,
|
|
createCompactionSummaryMessage,
|
|
} from "@oh-my-pi/pi-agent-core/compaction/messages";
|
|
|
|
import type { OutputMeta } from "../tools/output-meta";
|
|
import { formatOutputNotice } from "../tools/output-meta";
|
|
|
|
export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
|
|
export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
|
|
|
|
export interface SkillPromptDetails {
|
|
name: string;
|
|
path: string;
|
|
args?: string;
|
|
lineCount: number;
|
|
/** Internal: tag used by AgentSession to remove the pending-display chip
|
|
* from `#steeringMessages` / `#followUpMessages` when the agent consumes
|
|
* this message. Not surfaced to renderers; the `__` prefix signals
|
|
* "private". Optional — non-streaming skill prompts never set it. Stripped
|
|
* from persisted `details` by `SessionManager.appendCustomMessageEntry`
|
|
* via the `INTERNAL_DETAILS_FIELDS` allowlist below. */
|
|
__pendingDisplayTag?: string;
|
|
}
|
|
|
|
/** Sentinel value for `AssistantMessage.errorMessage` indicating that the abort
|
|
* was an *expected internal transition* (plan-mode → execution compaction)
|
|
* and must NOT surface as a red "Operation aborted" line. Distinct from
|
|
* `undefined` (default) so user-cancel aborts with no errorMessage still
|
|
* render normally. Persists through SessionManager so history replay
|
|
* branches identically.
|
|
*
|
|
* Consumers: `AgentSession.#handleAgentEvent` (stamper) writes this value;
|
|
* `EventController.#handleMessageEnd`, `AssistantMessageComponent`,
|
|
* `ui-helpers.addMessageToChat` (renderers), `SessionObserverOverlay
|
|
* #buildTranscriptLines`, `runPrintMode`, and `AcpAgent#replayAssistantMessage`
|
|
* (fallback error emission) read it via `isSilentAbort`. */
|
|
export const SILENT_ABORT_MARKER = "__omp.silent_abort__";
|
|
|
|
/** Type-guard for `SILENT_ABORT_MARKER`. Renderers MUST branch on this rather
|
|
* than string-comparing inline so refactors to the marker constant (e.g.,
|
|
* namespacing changes) propagate through every consumer in lockstep. */
|
|
export function isSilentAbort(errorMessage: string | undefined): boolean {
|
|
return errorMessage === SILENT_ABORT_MARKER;
|
|
}
|
|
|
|
/** Reason threaded through `AbortController.abort(reason)` when the user aborts
|
|
* the turn with Esc (see `AgentSession.abort`). The agent keeps it on the
|
|
* aborted assistant message's `errorMessage` so queued follow-ups/tool-result
|
|
* placeholders can distinguish a deliberate interrupt from a bare lifecycle
|
|
* abort, but interactive renderers suppress this redundant transcript line. */
|
|
export const USER_INTERRUPT_LABEL = "Interrupted by user";
|
|
|
|
export function isUserInterruptAbort(errorMessage: string | undefined): boolean {
|
|
return errorMessage === USER_INTERRUPT_LABEL;
|
|
}
|
|
|
|
export function shouldRenderAbortReason(errorMessage: string | undefined): boolean {
|
|
return !isSilentAbort(errorMessage) && !isUserInterruptAbort(errorMessage);
|
|
}
|
|
|
|
/** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
|
|
* reason (bare `abort()`). Renderers treat it as "no specific reason given". */
|
|
const GENERIC_ABORT_SENTINEL = "Request was aborted";
|
|
|
|
/** Resolve the operator-facing label for an aborted assistant turn. A custom
|
|
* abort reason threaded onto `errorMessage` is returned verbatim; aborts with
|
|
* no threaded reason fall back to the retry-aware generic label. Call
|
|
* `shouldRenderAbortReason` before rendering when user interrupts should stay
|
|
* visually quiet. */
|
|
export function resolveAbortLabel(errorMessage: string | undefined, retryAttempt = 0): string {
|
|
if (errorMessage && errorMessage !== GENERIC_ABORT_SENTINEL && !isSilentAbort(errorMessage)) {
|
|
return errorMessage;
|
|
}
|
|
if (retryAttempt > 0) {
|
|
return `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`;
|
|
}
|
|
return "Operation aborted";
|
|
}
|
|
|
|
/** Extract the optional `__pendingDisplayTag` field from a CustomMessage's
|
|
* `details` blob. Safe over `unknown`; returns undefined when the field is
|
|
* absent or non-string. */
|
|
export function readPendingDisplayTag(details: unknown): string | undefined {
|
|
if (typeof details !== "object" || details === null) return undefined;
|
|
const candidate = (details as { __pendingDisplayTag?: unknown }).__pendingDisplayTag;
|
|
return typeof candidate === "string" ? candidate : undefined;
|
|
}
|
|
|
|
/** Explicit allowlist of `details` field names that are AgentSession-internal
|
|
* transient bookkeeping and MUST be removed before SessionManager persists
|
|
* the CustomMessageEntry to disk. Scoped intentionally narrow: only fields
|
|
* declared here are stripped. Adding a new entry is a deliberate, reviewed
|
|
* change — unrelated future payload fields are never silently dropped. */
|
|
export const INTERNAL_DETAILS_FIELDS = ["__pendingDisplayTag"] as const;
|
|
|
|
/** Return a `details` copy with every key in `INTERNAL_DETAILS_FIELDS`
|
|
* removed. Returns the input unchanged when there is nothing to strip
|
|
* (null/non-object, or no listed fields present) so callers don't pay a
|
|
* clone cost on the common path. */
|
|
export function stripInternalDetailsFields<T>(details: T | undefined): T | undefined {
|
|
if (details == null || typeof details !== "object") return details;
|
|
const obj = details as Record<string, unknown>;
|
|
let hit = false;
|
|
for (const key of INTERNAL_DETAILS_FIELDS) {
|
|
if (key in obj) {
|
|
hit = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hit) return details;
|
|
const cleaned: Record<string, unknown> = { ...obj };
|
|
for (const key of INTERNAL_DETAILS_FIELDS) {
|
|
delete cleaned[key];
|
|
}
|
|
return cleaned as T;
|
|
}
|
|
|
|
function isSteeringUserMessage(message: AgentMessage | undefined): message is UserMessage & { steering: true } {
|
|
return message?.role === "user" && message.steering === true;
|
|
}
|
|
|
|
function userMessageWithoutSteering(message: UserMessage): UserMessage {
|
|
const { steering, ...rest } = message;
|
|
void steering;
|
|
return rest;
|
|
}
|
|
|
|
function renderSteeringEnvelope(message: string): string {
|
|
return prompt.render(userInterjectionTemplate, { message });
|
|
}
|
|
|
|
function getArrayContentText(content: (TextContent | ImageContent)[]): string {
|
|
let firstText: string | undefined;
|
|
let textParts: string[] | undefined;
|
|
for (const part of content) {
|
|
if (part.type !== "text") continue;
|
|
if (firstText === undefined) {
|
|
firstText = part.text;
|
|
continue;
|
|
}
|
|
if (textParts === undefined) {
|
|
textParts = [firstText];
|
|
}
|
|
textParts.push(part.text);
|
|
}
|
|
return textParts === undefined ? (firstText ?? "") : textParts.join("\n");
|
|
}
|
|
|
|
function getArrayContentImages(content: (TextContent | ImageContent)[]): ImageContent[] {
|
|
let images: ImageContent[] | undefined;
|
|
for (const part of content) {
|
|
if (part.type !== "image") continue;
|
|
if (images === undefined) images = [];
|
|
images.push(part);
|
|
}
|
|
return images ?? [];
|
|
}
|
|
|
|
function wrapSteeringUserMessage(message: UserMessage): UserMessage {
|
|
if (typeof message.content === "string") {
|
|
if (message.content.length === 0) return message;
|
|
return { ...userMessageWithoutSteering(message), content: renderSteeringEnvelope(message.content) };
|
|
}
|
|
|
|
const text = getArrayContentText(message.content);
|
|
if (text.length === 0) return message;
|
|
const content: (TextContent | ImageContent)[] = [{ type: "text", text: renderSteeringEnvelope(text) }];
|
|
content.push(...getArrayContentImages(message.content));
|
|
return { ...userMessageWithoutSteering(message), content };
|
|
}
|
|
|
|
export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] {
|
|
const last = messages[messages.length - 1];
|
|
if (!isSteeringUserMessage(last)) return messages;
|
|
|
|
let firstSteer = messages.length - 1;
|
|
while (firstSteer > 0 && isSteeringUserMessage(messages[firstSteer - 1])) {
|
|
firstSteer--;
|
|
}
|
|
|
|
let wrappedMessages: AgentMessage[] | undefined;
|
|
for (let i = firstSteer; i < messages.length; i++) {
|
|
const message = messages[i];
|
|
if (!isSteeringUserMessage(message)) continue;
|
|
const wrappedMessage = wrapSteeringUserMessage(message);
|
|
if (wrappedMessage === message) continue;
|
|
if (wrappedMessages === undefined) {
|
|
wrappedMessages = messages.slice();
|
|
}
|
|
wrappedMessages[i] = wrappedMessage;
|
|
}
|
|
return wrappedMessages ?? messages;
|
|
}
|
|
|
|
function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] {
|
|
if (message.prunedAt === undefined) {
|
|
return message.content;
|
|
}
|
|
const textBlocks = message.content.filter((content): content is TextContent => content.type === "text");
|
|
const text = textBlocks.map(block => block.text).join("") || "[Output truncated]";
|
|
return [{ type: "text", text }];
|
|
}
|
|
|
|
/** Result of filtering image blocks out of a `(TextContent | ImageContent)[]` array. */
|
|
interface StripContentResult {
|
|
content: (TextContent | ImageContent)[];
|
|
removed: number;
|
|
}
|
|
|
|
function stripImagesFromArrayContent(content: (TextContent | ImageContent)[]): StripContentResult {
|
|
let removed = 0;
|
|
const kept: (TextContent | ImageContent)[] = [];
|
|
for (const part of content) {
|
|
if (part.type === "image") {
|
|
removed++;
|
|
} else {
|
|
kept.push(part);
|
|
}
|
|
}
|
|
if (removed === 0) {
|
|
return { content, removed };
|
|
}
|
|
// Avoid emitting an empty `content` array — providers reject zero-block user/tool
|
|
// messages and the LLM still needs to see *something* where the image used to be.
|
|
if (kept.length === 0) {
|
|
kept.push({ type: "text", text: "[image removed]" });
|
|
}
|
|
return { content: kept, removed };
|
|
}
|
|
|
|
/**
|
|
* Strip image content blocks from `message` in place. Returns the count of
|
|
* images removed across `content` (every role that carries `ImageContent`) and
|
|
* any tool-result `details.images` payload. Callers MUST rewrite session
|
|
* entries (`SessionManager.rewriteEntries`) and replay them through
|
|
* `Agent.replaceMessages` afterwards so persisted state and provider-side
|
|
* caches stay aligned with the mutated tree — `stripImagesFromMessage` is a
|
|
* pure local mutation and intentionally does neither.
|
|
*/
|
|
export function stripImagesFromMessage(message: AgentMessage): number {
|
|
switch (message.role) {
|
|
case "user":
|
|
case "developer":
|
|
case "custom":
|
|
case "hookMessage": {
|
|
if (typeof message.content === "string") return 0;
|
|
const { content, removed } = stripImagesFromArrayContent(message.content);
|
|
if (removed > 0) {
|
|
// All four roles type `content` as `string | (TextContent | ImageContent)[]`;
|
|
// TypeScript can't narrow the assignment across the union, so cast once.
|
|
(message as { content: typeof content }).content = content;
|
|
}
|
|
return removed;
|
|
}
|
|
case "toolResult": {
|
|
let removed = 0;
|
|
const { content, removed: contentRemoved } = stripImagesFromArrayContent(message.content);
|
|
if (contentRemoved > 0) {
|
|
message.content = content;
|
|
removed += contentRemoved;
|
|
}
|
|
const details = message.details as { images?: unknown } | null | undefined;
|
|
if (details && Array.isArray(details.images)) {
|
|
const original = details.images as unknown[];
|
|
const kept: unknown[] = [];
|
|
for (const candidate of original) {
|
|
const looksLikeImageBlock =
|
|
!!candidate && typeof candidate === "object" && (candidate as { type?: unknown }).type === "image";
|
|
if (looksLikeImageBlock) {
|
|
removed++;
|
|
} else {
|
|
kept.push(candidate);
|
|
}
|
|
}
|
|
if (kept.length !== original.length) {
|
|
details.images = kept;
|
|
}
|
|
}
|
|
return removed;
|
|
}
|
|
case "fileMention": {
|
|
let removed = 0;
|
|
for (const file of message.files) {
|
|
if (file.image) {
|
|
file.image = undefined;
|
|
removed++;
|
|
}
|
|
}
|
|
return removed;
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Message type for bash executions via the ! command.
|
|
*/
|
|
export interface BashExecutionMessage {
|
|
role: "bashExecution";
|
|
command: string;
|
|
output: string;
|
|
exitCode: number | undefined;
|
|
cancelled: boolean;
|
|
truncated: boolean;
|
|
meta?: OutputMeta;
|
|
timestamp: number;
|
|
/** If true, this message is excluded from LLM context (!! prefix) */
|
|
excludeFromContext?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Message type for user-initiated Python executions via the $ command.
|
|
* Shares the same kernel session as eval's Python backend.
|
|
*/
|
|
export interface PythonExecutionMessage {
|
|
role: "pythonExecution";
|
|
code: string;
|
|
output: string;
|
|
exitCode: number | undefined;
|
|
cancelled: boolean;
|
|
truncated: boolean;
|
|
meta?: OutputMeta;
|
|
timestamp: number;
|
|
/** If true, this message is excluded from LLM context ($$ prefix) */
|
|
excludeFromContext?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Message type for extension-injected messages via sendMessage().
|
|
*/
|
|
export interface CustomMessage<T = unknown> {
|
|
role: "custom";
|
|
customType: string;
|
|
content: string | (TextContent | ImageContent)[];
|
|
display: boolean;
|
|
details?: T;
|
|
/** Who initiated this message for billing/attribution semantics. */
|
|
attribution?: MessageAttribution;
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* Legacy hook message type (pre-extensions). Kept for session migration.
|
|
*/
|
|
export interface HookMessage<T = unknown> {
|
|
role: "hookMessage";
|
|
customType: string;
|
|
content: string | (TextContent | ImageContent)[];
|
|
display: boolean;
|
|
details?: T;
|
|
/** Who initiated this message for billing/attribution semantics. */
|
|
attribution?: MessageAttribution;
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* Message type for auto-read file mentions via @filepath syntax.
|
|
*/
|
|
export interface FileMentionMessage {
|
|
role: "fileMention";
|
|
files: Array<{
|
|
path: string;
|
|
content: string;
|
|
lineCount?: number;
|
|
/** File size in bytes, if known. */
|
|
byteSize?: number;
|
|
/** Why the file contents were omitted from auto-read. */
|
|
skippedReason?: "tooLarge";
|
|
image?: ImageContent;
|
|
}>;
|
|
timestamp: number;
|
|
}
|
|
|
|
// Extend CustomAgentMessages via declaration merging
|
|
// Legacy hookMessage is kept for migration; new code should use custom.
|
|
declare module "@oh-my-pi/pi-agent-core" {
|
|
interface CustomAgentMessages {
|
|
bashExecution: BashExecutionMessage;
|
|
pythonExecution: PythonExecutionMessage;
|
|
custom: CustomMessage;
|
|
hookMessage: HookMessage;
|
|
branchSummary: BranchSummaryMessage;
|
|
compactionSummary: CompactionSummaryMessage;
|
|
fileMention: FileMentionMessage;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a BashExecutionMessage to user message text for LLM context.
|
|
*/
|
|
export function bashExecutionToText(msg: BashExecutionMessage): string {
|
|
let text = `Ran \`${msg.command}\`\n`;
|
|
if (msg.output) {
|
|
text += `\`\`\`\n${msg.output}\n\`\`\``;
|
|
} else {
|
|
text += "(no output)";
|
|
}
|
|
if (msg.cancelled) {
|
|
text += "\n\n(command cancelled)";
|
|
} else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
|
|
text += `\n\nCommand exited with code ${msg.exitCode}`;
|
|
}
|
|
text += formatOutputNotice(msg.meta);
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Convert a PythonExecutionMessage to user message text for LLM context.
|
|
*/
|
|
export function pythonExecutionToText(msg: PythonExecutionMessage): string {
|
|
let text = `Ran Python:\n\`\`\`python\n${msg.code}\n\`\`\`\n`;
|
|
if (msg.output) {
|
|
text += `Output:\n\`\`\`\n${msg.output}\n\`\`\``;
|
|
} else {
|
|
text += "(no output)";
|
|
}
|
|
if (msg.cancelled) {
|
|
text += "\n\n(execution cancelled)";
|
|
} else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
|
|
text += `\n\nExecution failed with code ${msg.exitCode}`;
|
|
}
|
|
text += formatOutputNotice(msg.meta);
|
|
return text;
|
|
}
|
|
|
|
export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: AssistantMessage): AssistantMessage {
|
|
if (message.providerPayload?.type !== "openaiResponsesHistory") {
|
|
return message;
|
|
}
|
|
|
|
let didSanitizeContent = false;
|
|
const sanitizedContent = message.content.map(block => {
|
|
if (block.type !== "thinking" || block.thinkingSignature === undefined) {
|
|
return block;
|
|
}
|
|
|
|
didSanitizeContent = true;
|
|
return { ...block, thinkingSignature: undefined };
|
|
});
|
|
|
|
// Strip the assistant-side native replay payload entirely.
|
|
// After rehydration it belongs to a previous live provider connection and
|
|
// replaying it on a warmed session causes 401 rejections from GitHub Copilot.
|
|
// User/developer payloads are preserved separately by the caller.
|
|
return {
|
|
...message,
|
|
...(didSanitizeContent ? { content: sanitizedContent } : {}),
|
|
providerPayload: undefined,
|
|
};
|
|
}
|
|
|
|
/** Convert CustomMessageEntry to AgentMessage format */
|
|
export function createCustomMessage(
|
|
customType: string,
|
|
content: string | (TextContent | ImageContent)[],
|
|
display: boolean,
|
|
details: unknown | undefined,
|
|
timestamp: string,
|
|
attribution?: MessageAttribution,
|
|
): CustomMessage {
|
|
return {
|
|
role: "custom",
|
|
customType,
|
|
content,
|
|
display,
|
|
details,
|
|
attribution,
|
|
timestamp: new Date(timestamp).getTime(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Transform AgentMessages (including custom types) to LLM-compatible Messages.
|
|
*
|
|
* This is used by:
|
|
* - Agent's transormToLlm option (for prompt calls and queued messages)
|
|
* - Compaction's generateSummary (for summarization)
|
|
* - Custom extensions and tools
|
|
*/
|
|
export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
return messages
|
|
.map((m): Message | undefined => {
|
|
switch (m.role) {
|
|
case "bashExecution":
|
|
if (m.excludeFromContext) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
role: "user",
|
|
content: [{ type: "text", text: bashExecutionToText(m) }],
|
|
attribution: "user",
|
|
timestamp: m.timestamp,
|
|
};
|
|
case "pythonExecution":
|
|
if (m.excludeFromContext) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
role: "user",
|
|
content: [{ type: "text", text: pythonExecutionToText(m) }],
|
|
attribution: "user",
|
|
timestamp: m.timestamp,
|
|
};
|
|
case "custom":
|
|
case "hookMessage": {
|
|
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
|
|
const role = "developer";
|
|
const attribution = m.attribution;
|
|
return {
|
|
role,
|
|
content,
|
|
attribution,
|
|
timestamp: m.timestamp,
|
|
};
|
|
}
|
|
case "branchSummary":
|
|
return {
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: renderBranchSummaryContext(m.summary),
|
|
},
|
|
],
|
|
attribution: "agent",
|
|
timestamp: m.timestamp,
|
|
};
|
|
case "compactionSummary":
|
|
return {
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: renderCompactionSummaryContext(m.summary),
|
|
},
|
|
],
|
|
attribution: "agent",
|
|
providerPayload: m.providerPayload,
|
|
timestamp: m.timestamp,
|
|
};
|
|
case "fileMention": {
|
|
const fileContents = m.files
|
|
.map(file => {
|
|
const inner = file.content ? `\n${file.content}\n` : "\n";
|
|
return `<file path="${file.path}">${inner}</file>`;
|
|
})
|
|
.join("\n");
|
|
const content: (TextContent | ImageContent)[] = [{ type: "text" as const, text: fileContents }];
|
|
for (const file of m.files) {
|
|
if (file.image) {
|
|
content.push(file.image);
|
|
}
|
|
}
|
|
return {
|
|
role: "developer",
|
|
content,
|
|
attribution: "user",
|
|
timestamp: m.timestamp,
|
|
};
|
|
}
|
|
case "user":
|
|
return { ...m, attribution: m.attribution ?? "user" };
|
|
case "developer":
|
|
return { ...m, attribution: m.attribution ?? "agent" };
|
|
case "assistant":
|
|
return m;
|
|
case "toolResult":
|
|
return {
|
|
...m,
|
|
content: getPrunedToolResultContent(m as ToolResultMessage),
|
|
attribution: m.attribution ?? "agent",
|
|
};
|
|
default:
|
|
m satisfies never;
|
|
return undefined;
|
|
}
|
|
})
|
|
.filter(m => m !== undefined);
|
|
}
|