kimi-code/packages/node-sdk/src/session.ts
Haozhe 8c766a6c30
feat(agent-core-v2): add the L3 unit layer and the Feature seam (#2678)
* feat(agent-core-v2): add the L3 unit layer and the Feature seam

- introduce the L3 Service/Fiber unit layer: the Service base class with this.provide/effect/on/get/ref capabilities, the fiber runtime with thenable FiberHandles, collection contribution points, and the per-scope-kind ScopeUnits materialization fold
- provide each scope's static registration batch as one atomic provideAll cascade transaction (waiting-area activation, sticky Failed on construction error)
- add the DI unit inspection surface: App-scope debug ledger / dependency graph / cascade history services and the kimi-inspect DI view
- add the Feature unit seam (IFeatureManager + feature assembly), port plan mode onto it, and add the contributed-command seam (agent-command domain + node-sdk RPC types)
- remove the legacy dep-graph tooling
- apply the header-only comment convention across src and test: strip non-header narration, keep the file header, tooling pragmas, and NOTE comments

* feat(kap-server): gate the event.di.* debug feed to kimi-inspect connections

- add an opt-in target set in SessionEventBroadcaster; the global fan-out
  now skips event.di.* frames for connections that never opted in, so
  kimi-web and other clients no longer receive the high-churn DI feed
- WsConnectionV1 opts a connection in when client_hello carries
  client_id 'kimi-inspect'; removeGlobalTarget drops the opt-in on close
- temporary gate until a client-declared event-type whitelist lands

* chore(agent-core-v2): fix oxlint errors in the DI unit layer

- build the live-ref container chain without aliasing this (no-this-alias)
- snapshot the materialized map with Array.from and document why the copy
  is required (no-useless-spread)

* test(klient): use string scope kinds in the lifecycle handle fakes

The engine's LifecycleScope is a string enum now; the facade test doubles
still returned the old numeric kinds and failed the handleWireSchema output
validation.

* build(nix): update the pnpmDeps fetch hash
2026-08-06 18:22:36 +08:00

789 lines
24 KiB
TypeScript

import {
ErrorCodes,
KimiError,
type AgentContextData,
type KimiErrorCode,
type SwarmModeTrigger,
} from '@moonshot-ai/agent-core';
import { type ApprovalHandler, type Event, type QuestionHandler } from '#/events';
import type { SDKRpcClientBase } from '#/rpc';
import type {
AddAdditionalDirOptions,
AddAdditionalDirResult,
AgentCommandInfo,
BackgroundTaskInfo,
CapabilityStatus,
CompactOptions,
CreateGoalInput,
GetCronTasksResult,
GoalSnapshot,
GoalToolResult,
JsonObject,
McpServerInfo,
McpStartupMetrics,
PermissionMode,
PluginInfo,
PluginSummary,
PromptInput,
ReloadSessionOptions,
ReloadSummary,
ResumedSessionState,
ResumedSessionSummary,
SessionPlan,
SessionStatus,
SessionSummary,
SessionUsage,
SkillSummary,
PluginCommandDef,
ThinkingEffort,
Unsubscribe,
} from '#/types';
const MAIN_AGENT_ID = 'main';
export interface SessionOptions {
readonly id: string;
readonly workDir: string;
readonly summary?: SessionSummary | undefined;
readonly resumeState?: ResumedSessionState | undefined;
readonly rpc: SDKRpcClientBase;
readonly onClose?: (() => void | Promise<void>) | undefined;
}
/**
* The capability surface (built-in product capabilities: kimi-cu,
* kimi-webbridge) exists only on the v2 engine — v1 has no capability
* domain. Feature-detect structurally so a Session backed by v1 fails with
* a clear message instead of a confusing missing-method error.
*/
interface CapabilityRpcSurface {
listCapabilities(): Promise<readonly CapabilityStatus[]>;
getCapability(id: string): Promise<CapabilityStatus>;
installCapability(id: string): Promise<CapabilityStatus>;
}
export function capabilityRpc(rpc: SDKRpcClientBase): CapabilityRpcSurface {
const candidate = rpc as Partial<CapabilityRpcSurface>;
if (
typeof candidate.listCapabilities !== 'function' ||
typeof candidate.getCapability !== 'function' ||
typeof candidate.installCapability !== 'function'
) {
throw new TypeError('The capability surface is unavailable on this engine (requires v2).');
}
return candidate as CapabilityRpcSurface;
}
export class Session {
readonly id: string;
readonly workDir: string;
summary?: SessionSummary | undefined;
private resumeState: ResumedSessionState | undefined;
private readonly rpc: SDKRpcClientBase;
private readonly onClose?: (() => void | Promise<void>) | undefined;
private closed = false;
constructor(options: SessionOptions) {
this.id = options.id;
this.workDir = options.workDir;
this.summary = options.summary;
this.resumeState = options.resumeState ?? resumeStateFromSummary(options.summary);
this.rpc = options.rpc;
this.onClose = options.onClose;
}
getResumeState(): ResumedSessionState | undefined {
this.ensureOpen();
return this.resumeState;
}
async reloadSession(options?: ReloadSessionOptions): Promise<ResumedSessionSummary> {
this.ensureOpen();
const summary = await this.rpc.reloadSession({
sessionId: this.id,
forcePluginSessionStartReminder: options?.forcePluginSessionStartReminder,
});
this.summary = summary;
this.resumeState = resumeStateFromSummary(summary);
return summary;
}
onEvent(listener: (event: Event) => void): Unsubscribe {
this.ensureOpen();
return this.rpc.onEvent((event) => {
if (event.sessionId === this.id) {
listener(event);
}
});
}
setApprovalHandler(handler: ApprovalHandler | undefined): void {
this.ensureOpen();
this.rpc.setApprovalHandler(this.id, handler);
}
setQuestionHandler(handler: QuestionHandler | undefined): void {
this.ensureOpen();
this.rpc.setQuestionHandler(this.id, handler);
}
async prompt(input: string | PromptInput): Promise<void> {
this.ensureOpen();
await this.rpc.prompt({
sessionId: this.id,
input: normalizePromptInput(input),
});
}
/** Execute a user-initiated `!` shell command (silent — does not prompt the
* model). Resolves with the command's stdout/stderr for immediate display.
* Pass `commandId` to receive live `shell.output` events for this command. */
async runShellCommand(
command: string,
options?: { commandId?: string },
): Promise<{ stdout: string; stderr: string; isError?: boolean; backgrounded?: boolean }> {
this.ensureOpen();
return this.rpc.runShellCommand({
sessionId: this.id,
command,
commandId: options?.commandId,
});
}
/** Cancel a running `!` shell command by its commandId (e.g. on Esc / Ctrl+C). */
async cancelShellCommand(commandId: string): Promise<void> {
this.ensureOpen();
return this.rpc.cancelShellCommand({ sessionId: this.id, commandId });
}
async steer(input: string | PromptInput): Promise<void> {
this.ensureOpen();
await this.rpc.steer({
sessionId: this.id,
input: normalizePromptInput(input),
});
}
async swarm(input: string | PromptInput): Promise<void> {
this.ensureOpen();
await this.rpc.swarm({
sessionId: this.id,
input: normalizePromptInput(input),
});
}
async init(): Promise<void> {
this.ensureOpen();
await this.rpc.generateAgentsMd({ sessionId: this.id });
}
async getSessionWarnings() {
this.ensureOpen();
return this.rpc.getSessionWarnings({ sessionId: this.id });
}
async addAdditionalDir(
path: string,
options?: AddAdditionalDirOptions,
): Promise<AddAdditionalDirResult> {
this.ensureOpen();
const normalized = normalizeRequiredString(
path,
'Additional directory cannot be empty',
ErrorCodes.REQUEST_INVALID,
);
const result = await this.rpc.addAdditionalDir({
id: this.id,
path: normalized,
persist: options?.persist ?? true,
});
this.summary = { ...this.requireSummary(), additionalDirs: result.additionalDirs };
return result;
}
async startBtw(): Promise<string> {
this.ensureOpen();
return this.rpc.startBtw({ sessionId: this.id });
}
async cancel(): Promise<void> {
this.ensureOpen();
await this.rpc.cancel({ sessionId: this.id });
}
async setModel(model: string): Promise<void> {
this.ensureOpen();
const normalized = normalizeRequiredString(
model,
'Session model cannot be empty',
ErrorCodes.SESSION_MODEL_EMPTY,
);
await this.rpc.setModel({ sessionId: this.id, model: normalized });
}
async setThinking(effort: ThinkingEffort): Promise<void> {
this.ensureOpen();
const normalized = normalizeRequiredString(
effort,
'Session thinking effort cannot be empty',
ErrorCodes.SESSION_THINKING_EMPTY,
);
await this.rpc.setThinking({ sessionId: this.id, effort: normalized });
}
/**
* Live-apply the persisted `[secondary_model]` recipe to this session
* (subagent model binding). Persist the recipe via `KimiHarness.setConfig`
* first; this reloads the complete recipe and its synthesized derived entry
* before updating the session snapshot — mirroring the `/secondary_model`
* flow.
*/
async applyPersistedSecondaryModel(): Promise<void> {
this.ensureOpen();
await this.rpc.applyPersistedSecondaryModel({ sessionId: this.id });
}
async setPermission(mode: PermissionMode): Promise<void> {
this.ensureOpen();
if (!isPermissionMode(mode)) {
throw new KimiError(
ErrorCodes.SESSION_PERMISSION_MODE_INVALID,
'Session permission mode must be yolo, manual, or auto',
);
}
await this.rpc.setPermission({ sessionId: this.id, mode });
}
/** Shallow-merge host-owned fields into this session's persisted custom metadata. */
async updateMetadata(patch: JsonObject): Promise<void> {
this.ensureOpen();
if (Object.hasOwn(patch, 'goal')) {
throw new KimiError(
ErrorCodes.GOAL_METADATA_RESERVED,
'Session metadata key "goal" is reserved for the goal lifecycle',
);
}
const summary = this.requireSummary();
await this.rpc.updateSessionMetadata({ sessionId: this.id, metadata: patch });
const metadata = { ...summary.metadata, ...patch };
this.summary = { ...summary, metadata };
if (this.resumeState !== undefined) {
this.resumeState = {
...this.resumeState,
sessionMetadata: {
...this.resumeState.sessionMetadata,
custom: { ...this.resumeState.sessionMetadata.custom, ...patch },
},
};
}
}
async setPlanMode(enabled: boolean): Promise<void> {
this.ensureOpen();
if (typeof enabled !== 'boolean') {
throw new KimiError(
ErrorCodes.SESSION_PLAN_MODE_INVALID,
'Session plan mode must be a boolean',
);
}
await this.rpc.setPlanMode({ sessionId: this.id, enabled });
}
async setSwarmMode(enabled: boolean, trigger: SwarmModeTrigger): Promise<void> {
this.ensureOpen();
if (typeof enabled !== 'boolean') {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
'Session swarm mode must be a boolean',
);
}
if (enabled) {
await this.rpc.setSwarmMode({ sessionId: this.id, enabled: true, trigger });
} else {
await this.rpc.setSwarmMode({ sessionId: this.id, enabled: false });
}
}
async getPlan(): Promise<SessionPlan> {
this.ensureOpen();
return this.rpc.getPlan({ sessionId: this.id });
}
async clearPlan(): Promise<void> {
this.ensureOpen();
await this.rpc.clearPlan({ sessionId: this.id });
}
async compact(options: CompactOptions = {}): Promise<void> {
this.ensureOpen();
const instruction = normalizeOptionalString(options.instruction);
await this.rpc.compact({
sessionId: this.id,
...(instruction !== undefined ? { instruction } : {}),
});
}
async cancelCompaction(): Promise<void> {
this.ensureOpen();
await this.rpc.cancelCompaction({ sessionId: this.id });
}
async undoHistory(count: number = 1): Promise<void> {
this.ensureOpen();
await this.rpc.undoHistory({ sessionId: this.id, count });
}
/** Clear this session's model context without creating a new session. */
async clearContext(): Promise<void> {
this.ensureOpen();
await this.rpc.clearContext({ sessionId: this.id });
}
/** Append imported text to this session's context without prompting the model. */
async importContext(content: string, source: string): Promise<void> {
this.ensureOpen();
await this.rpc.importContext({ sessionId: this.id, content, source });
}
async getContext(): Promise<AgentContextData> {
this.ensureOpen();
return this.rpc.getContext({ sessionId: this.id });
}
async getUsage(): Promise<SessionUsage> {
this.ensureOpen();
return this.rpc.getUsage({ sessionId: this.id });
}
async getStatus(): Promise<SessionStatus> {
this.ensureOpen();
return this.rpc.getStatus({ sessionId: this.id });
}
async listSkills(): Promise<readonly SkillSummary[]> {
this.ensureOpen();
return this.rpc.listSkills({ sessionId: this.id });
}
async listPluginCommands(): Promise<readonly PluginCommandDef[]> {
this.ensureOpen();
return this.rpc.listPluginCommands({ sessionId: this.id });
}
/**
* Contributed commands registered with this session's interactive agent
* (agent-core-v2 only — a v1-backed session reports the empty set).
*/
async listCommands(): Promise<readonly AgentCommandInfo[]> {
this.ensureOpen();
return this.rpc.listCommands({ sessionId: this.id });
}
/**
* List background tasks for this session's interactive agent.
*
* Defaults to all tasks (including terminal/lost). Pass
* `{ activeOnly: true }` to filter to non-terminal entries.
*/
async listBackgroundTasks(
options: { activeOnly?: boolean; limit?: number } = {},
): Promise<readonly BackgroundTaskInfo[]> {
this.ensureOpen();
return this.rpc.listBackgroundTasks({
sessionId: this.id,
activeOnly: options.activeOnly,
limit: options.limit,
});
}
/**
* Read a background task's captured output. Returns the in-memory
* ring buffer if available, otherwise falls back to the persisted
* `<sessionDir>/tasks/<taskId>/output.log`. `tail` caps the returned
* string to that many trailing characters.
*/
async getBackgroundTaskOutput(
taskId: string,
options: { tail?: number } = {},
): Promise<string> {
this.ensureOpen();
const trimmedTaskId = normalizeRequiredString(
taskId,
'Task id cannot be empty',
ErrorCodes.BACKGROUND_TASK_ID_EMPTY,
);
return this.rpc.getBackgroundTaskOutput({
sessionId: this.id,
taskId: trimmedTaskId,
tail: options.tail,
});
}
/**
* Request a running background task to stop. Sends SIGTERM with a
* grace period (handled by the core BPM); subscribers receive a
* `background.task.terminated` event when the kill settles. Calls
* for unknown or already-terminal task ids are no-ops at the core
* level — this method does not throw in those cases.
*/
async stopBackgroundTask(
taskId: string,
options: { reason?: string } = {},
): Promise<void> {
this.ensureOpen();
const trimmedTaskId = normalizeRequiredString(
taskId,
'Task id cannot be empty',
ErrorCodes.BACKGROUND_TASK_ID_EMPTY,
);
await this.rpc.stopBackgroundTask({
sessionId: this.id,
taskId: trimmedTaskId,
reason: options.reason,
});
}
/**
* Detach a running foreground task so the current tool call can return while
* the task continues under background-task management.
*/
async detachBackgroundTask(taskId: string): Promise<BackgroundTaskInfo | undefined> {
this.ensureOpen();
const trimmedTaskId = normalizeRequiredString(
taskId,
'Task id cannot be empty',
ErrorCodes.BACKGROUND_TASK_ID_EMPTY,
);
return this.rpc.detachBackgroundTask({
sessionId: this.id,
taskId: trimmedTaskId,
});
}
/**
* Block until every still-running background task (across all agents in this
* session) reaches a terminal state. Used by `kimi -p` after the main agent's
* turn finishes when the resolved print background mode is `'drain'`
* (`print_background_mode = "drain"`, or the legacy `keep_alive_on_exit = true`
* fallback), so background subagents get a chance to complete before the process
* exits. No-op in other modes. Bounded by `background.print_wait_ceiling_s`.
*/
async waitForBackgroundTasksOnPrint(): Promise<void> {
this.ensureOpen();
await this.rpc.waitForBackgroundTasksOnPrint({ sessionId: this.id });
}
/**
* Used by `kimi -p` after the main agent's turn ends with `reason ===
* 'completed'`. Returns `'finish'` when the run may exit, or `'continue'` when
* the caller must keep the session alive so a background-task completion can
* steer the main agent into a new turn. Policy is selected by
* `background.print_background_mode` (`'exit' | 'drain' | 'steer'`); when unset
* it falls back to the legacy `keep_alive_on_exit` mapping (`true ⇒ 'drain'`).
*/
async handlePrintMainTurnCompleted(): Promise<'finish' | 'continue'> {
this.ensureOpen();
return this.rpc.handlePrintMainTurnCompleted({ sessionId: this.id });
}
// --- Goal lifecycle ---------------------------------------------------
// Deterministic user/host control surface. There is intentionally no
// `updateGoal`: the goal's terminal status is decided by the model via the
// in-conversation UpdateGoal tool (or the goal driver on budget/error), not
// by the host.
async createGoal(input: CreateGoalInput): Promise<GoalSnapshot> {
this.ensureOpen();
return this.rpc.createGoal({ sessionId: this.id, ...input });
}
async getGoal(): Promise<GoalToolResult> {
this.ensureOpen();
return this.rpc.getGoal({ sessionId: this.id });
}
async pauseGoal(): Promise<GoalSnapshot> {
this.ensureOpen();
return this.rpc.pauseGoal({ sessionId: this.id });
}
async resumeGoal(): Promise<GoalSnapshot> {
this.ensureOpen();
return this.rpc.resumeGoal({ sessionId: this.id });
}
async cancelGoal(): Promise<GoalSnapshot> {
this.ensureOpen();
return this.rpc.cancelGoal({ sessionId: this.id });
}
/**
* Enumerate the cron tasks scheduled in this session. Hosts running a
* bounded session lifetime (e.g. `kimi -p`) poll this to decide whether
* pending scheduled work still needs the process alive.
*/
async getCronTasks(): Promise<GetCronTasksResult> {
this.ensureOpen();
return this.rpc.getCronTasks({ sessionId: this.id });
}
async listMcpServers(): Promise<readonly McpServerInfo[]> {
this.ensureOpen();
return this.rpc.listMcpServers({ sessionId: this.id });
}
async getMcpStartupMetrics(): Promise<McpStartupMetrics> {
this.ensureOpen();
return this.rpc.getMcpStartupMetrics({ sessionId: this.id });
}
async reconnectMcpServer(name: string): Promise<void> {
this.ensureOpen();
await this.rpc.reconnectMcpServer({ sessionId: this.id, name });
}
async listPlugins(): Promise<readonly PluginSummary[]> {
this.ensureOpen();
return this.rpc.listPlugins();
}
async installPlugin(source: string): Promise<PluginSummary> {
this.ensureOpen();
return this.rpc.installPlugin(source);
}
async setPluginEnabled(id: string, enabled: boolean): Promise<void> {
this.ensureOpen();
await this.rpc.setPluginEnabled(id, enabled);
}
/** Built-in capabilities with layered readiness (v2 engine only). */
async listCapabilities(): Promise<readonly CapabilityStatus[]> {
this.ensureOpen();
return capabilityRpc(this.rpc).listCapabilities();
}
/** One capability's layered readiness + live install progress. */
async getCapability(id: string): Promise<CapabilityStatus> {
this.ensureOpen();
return capabilityRpc(this.rpc).getCapability(id);
}
/**
* Start an idempotent capability install (binary runtime + wiring) in the
* background; poll `getCapability` for progress.
*/
async installCapability(id: string): Promise<CapabilityStatus> {
this.ensureOpen();
return capabilityRpc(this.rpc).installCapability(id);
}
async setPluginMcpServerEnabled(
id: string,
server: string,
enabled: boolean,
): Promise<void> {
this.ensureOpen();
await this.rpc.setPluginMcpServerEnabled(id, server, enabled);
}
async removePlugin(id: string): Promise<void> {
this.ensureOpen();
await this.rpc.removePlugin(id);
}
async reloadPlugins(): Promise<ReloadSummary> {
this.ensureOpen();
return this.rpc.reloadPlugins();
}
async getPluginInfo(id: string): Promise<PluginInfo> {
this.ensureOpen();
return this.rpc.getPluginInfo(id);
}
async activateSkill(name: string, args?: string | undefined): Promise<void> {
this.ensureOpen();
const skillName = normalizeRequiredString(
name,
'Skill name cannot be empty',
ErrorCodes.SKILL_NAME_EMPTY,
);
const skillArgs = normalizeOptionalString(args);
await this.rpc.activateSkill({
sessionId: this.id,
name: skillName,
...(skillArgs !== undefined ? { args: skillArgs } : {}),
});
}
async activatePluginCommand(
pluginId: string,
commandName: string,
args?: string | undefined,
): Promise<void> {
this.ensureOpen();
const normalizedPluginId = pluginId.trim();
const normalizedCommandName = commandName.trim();
if (normalizedPluginId.length === 0 || normalizedCommandName.length === 0) {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
'Plugin id and command name cannot be empty',
);
}
const commandArgs = normalizeOptionalString(args);
await this.rpc.activatePluginCommand({
sessionId: this.id,
pluginId: normalizedPluginId,
commandName: normalizedCommandName,
...(commandArgs !== undefined ? { args: commandArgs } : {}),
});
}
/**
* Run a contributed command engine-side (agent-core-v2 only — a v1-backed
* client rejects with `not_implemented`). Unknown names reject with the
* engine's `request.invalid` error.
*/
async runCommand(name: string, args?: string): Promise<void> {
this.ensureOpen();
const commandName = name.trim();
if (commandName.length === 0) {
throw new KimiError(ErrorCodes.REQUEST_INVALID, 'Command name cannot be empty');
}
await this.rpc.runCommand({
sessionId: this.id,
name: commandName,
args: normalizeOptionalString(args),
});
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
try {
await this.rpc.closeSession({ sessionId: this.id });
} finally {
this.rpc.clearSessionHandlers(this.id);
await this.onClose?.();
}
}
/** @internal */
emitMetaUpdated(patch: { readonly title?: string | undefined }): void {
this.emit({
type: 'session.meta.updated',
sessionId: this.id,
agentId: MAIN_AGENT_ID,
title: patch.title,
patch,
});
}
private emit(event: Event): void {
this.rpc.receiveEvent(event);
}
private ensureOpen(): void {
if (this.closed) {
throw new KimiError(ErrorCodes.SESSION_CLOSED, 'Session is closed');
}
}
private requireSummary(): SessionSummary {
if (this.summary === undefined) {
throw new KimiError(ErrorCodes.SESSION_STATE_INVALID, 'Session summary is unavailable');
}
return this.summary;
}
}
function normalizePromptInput(input: string | PromptInput): PromptInput {
if (typeof input === 'string') {
if (input.trim().length === 0) {
throw new KimiError(ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY, 'Prompt input cannot be empty');
}
return [{ type: 'text', text: input }];
}
if (input.length === 0) {
throw new KimiError(ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY, 'Prompt input cannot be empty');
}
for (const part of input) {
switch (part.type) {
case 'text':
if (part.text.trim().length === 0) {
throw new KimiError(
ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY,
'Prompt input cannot contain empty text parts',
);
}
break;
case 'image_url':
if (part.imageUrl.url.trim().length === 0) {
throw new KimiError(
ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY,
'Prompt input cannot contain empty image URLs',
);
}
break;
case 'video_url':
if (part.videoUrl.url.trim().length === 0) {
throw new KimiError(
ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY,
'Prompt input cannot contain empty video URLs',
);
}
break;
}
}
return input;
}
function normalizeRequiredString(
value: string,
message: string,
code: KimiErrorCode,
): string {
const normalized = value.trim();
if (normalized.length === 0) {
throw new KimiError(code, message);
}
return normalized;
}
function normalizeOptionalString(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
function isPermissionMode(value: unknown): value is PermissionMode {
return value === 'yolo' || value === 'manual' || value === 'auto';
}
function resumeStateFromSummary(
summary: SessionSummary | undefined,
): ResumedSessionState | undefined {
if (!hasResumeState(summary)) return undefined;
return {
sessionMetadata: summary.sessionMetadata,
agents: summary.agents,
warning: summary.warning,
};
}
function hasResumeState(
summary: SessionSummary | undefined,
): summary is SessionSummary & ResumedSessionState {
return (
summary !== undefined &&
typeof (summary as { readonly sessionMetadata?: unknown }).sessionMetadata === 'object' &&
(summary as { readonly sessionMetadata?: unknown }).sessionMetadata !== null &&
typeof (summary as { readonly agents?: unknown }).agents === 'object' &&
(summary as { readonly agents?: unknown }).agents !== null
);
}