SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw History Blame Contribute Delete
80.4 kB
import {
daemonFileRefFromPart,
readTodoItems,
type AgentTaskInfo,
type ContentPart,
type TokenUsage,
} from '@moonshot-ai/agent-core-v2';
import type {
AssistantMessage,
ContentPart as WireContentPart,
InteractionMessage,
ServerMessage,
StepMessage,
StepRetry,
StepTiming,
StepUsage,
SystemMessage,
TaskMessage,
TaskNotificationPayload,
ThinkingMessage,
TodoMessage,
ToolCallAgentRef,
ToolCallMessage,
ToolProgressPayload,
TurnMessage,
TurnOrigin,
UserMessage,
UserMessageOrigin,
} from '../../protocol/messages';
import { PROJECTION_IGNORED_EVENT_TYPES, type ProjectionBusEvent } from './events';
import type { WireTurnFold } from './heal';
import {
SystemIdAllocator,
TODO_ENTITY_ID,
attachmentIdOf,
isCompactionSystemId,
isUndoAnchorOrigin,
stepIdOf,
textMessageIdOf,
turnIdOf,
turnOrdinalOf,
turnUserMessageIdOf,
} from './ids';
const TASK_OUTPUT_TAIL_MAX = 8192;
const PENDING_CLEAR_SETTLE_MS = 100;
export interface ProjectorInteraction {
readonly id: string;
readonly kind: 'approval' | 'question';
readonly payload: unknown;
readonly createdAt: number;
}
export interface ProjectorLookups {
readonly stepOrdinal?: (turnId: string) => number | undefined;
readonly resolvePlanRevisionKey?: (key: string) => string;
}
export interface ProjectorHooks {
readonly onUnknownEvent?: (type: string) => void;
readonly onDeferred?: (messages: ServerMessage[]) => void;
}
interface TurnRecord {
turnId: string;
ordinal: number;
status: 'running' | 'completed';
origin: TurnOrigin;
anchor: boolean;
promptId?: string;
userMessageId?: string;
attachmentIds?: string[];
openingKey?: { text: string; attachments: number };
openingSteerDeduped: boolean;
startedAt?: string;
endedAt?: string;
durationMs?: number;
usage?: StepUsage;
}
interface StepRecord {
stepId: string;
turnId: string;
ordinal: number;
status: 'running' | 'completed' | 'interrupted' | 'failed';
startedAt?: string;
endedAt?: string;
usage?: StepUsage;
finishReason?: string;
timing?: StepTiming;
retry?: StepRetry;
endReason?: string;
endMessage?: string;
}
interface TextRecord {
messageId: string;
kind: 'assistant' | 'thinking';
turnId: string;
stepId: string;
status: 'streaming' | 'completed';
text: string;
}
interface ToolRecord {
toolCallId: string;
turnId: string;
stepId: string;
name: string;
status: 'running' | 'done' | 'error';
input?: unknown;
inputText?: string;
output?: unknown;
display?: unknown;
error?: string;
progress?: ToolProgressPayload;
taskId?: string;
approvalId?: string;
todoId?: string;
agentRefs: ToolCallAgentRef[];
startedAt?: string;
}
interface TaskRecord {
taskId: string;
kind: TaskMessage['kind'];
status: TaskMessage['status'];
detached: boolean;
description?: string;
childAgentId?: string;
outputTail: string;
startedAt?: string;
endedAt?: string;
resultSummary?: string;
error?: string;
stateReason?: string;
usage?: StepUsage;
model?: string;
thinkingEffort?: string;
}
interface InteractionRecord {
interactionId: string;
kind: 'approval' | 'question';
status: InteractionMessage['status'];
toolCallId?: string;
request?: unknown;
response?: unknown;
}
interface UserRecord {
messageId: string;
turnId?: string;
text: WireContentPart[];
status: 'unread' | 'read';
timestamp?: number;
origin?: UserMessageOrigin;
attachmentIds?: string[];
skillActivations?: { skill_name: string; skill_args?: string }[];
}
interface PromptRecord {
promptId: string;
userMessageId: string;
content: readonly ContentPart[];
status: 'running' | 'queued' | 'steered' | 'completed' | 'aborted';
createdAt: string;
}
export class AgentMessageProjector {
private currentTurn: TurnRecord | undefined;
private currentStep: StepRecord | undefined;
private openText: TextRecord | undefined;
private openThinking: TextRecord | undefined;
private serverUserSeq = 0;
private attachmentSeq = 0;
private phantomUserSeq = 0;
private readonly turns = new Map<string, TurnRecord>();
private readonly steps = new Map<string, StepRecord>();
private readonly texts = new Map<string, TextRecord>();
private readonly stepTextIds = new Map<string, { assistant?: string; thinking?: string }>();
private readonly stepTextSeqs = new Map<string, number>();
private readonly tools = new Map<string, ToolRecord>();
private readonly tasks = new Map<string, TaskRecord>();
private readonly shellTasks = new Map<string, string>();
private readonly interactions = new Map<string, InteractionRecord>();
private readonly users = new Map<string, UserRecord>();
private readonly prompts = new Map<string, PromptRecord>();
private readonly stepOrdinals = new Map<string, number>();
private readonly stepUsageByTurn = new Map<string, StepUsage[]>();
private mergedSteers: { text: string; promptIds: string[] }[] = [];
private pendingFullCut = false;
private pendingClearTimer: NodeJS.Timeout | undefined;
private todoItems: { title: string; status: 'pending' | 'in_progress' | 'done' }[] | undefined;
private todoUpdatedAt: string | undefined;
private planMode = false;
private swarmMode = false;
private readonly timelineIds: string[] = [];
private readonly sysIds = new SystemIdAllocator();
private readonly endedTurnOrdinals: number[] = [];
private readonly anchorTurnOrdinals = new Set<number>();
private timelineRewriteCount = 0;
private nextTurnIdHint = 0;
constructor(
readonly agentId: string,
private readonly sessionId: string,
private readonly subagentTaskIds: Map<string, string>,
private readonly lookups?: ProjectorLookups,
private readonly hooks?: ProjectorHooks,
) {}
map(event: ProjectionBusEvent): ServerMessage[] {
switch (event.type) {
case 'plan.revision':
return this.onPlanRevision(event);
case 'turn.started':
return this.onTurnStarted(event);
case 'turn.ended':
return this.onTurnEnded(event);
case 'turn.step.started':
return this.onStepStarted(event);
case 'turn.step.completed':
return this.onStepCompleted(event);
case 'turn.step.interrupted':
return this.onStepInterrupted(event);
case 'turn.step.retrying':
return this.onStepRetrying(event);
case 'assistant.delta':
return this.onTextDelta(event, 'assistant');
case 'thinking.delta':
return this.onTextDelta(event, 'thinking');
case 'tool.call.delta':
return this.onToolCallDelta(event);
case 'tool.progress':
return this.onToolProgress(event);
case 'tool.call.started':
return this.onToolCallStarted(event);
case 'tool.result':
return this.onToolResult(event);
case 'task.started':
case 'task.terminated':
return this.onTaskLifecycle(event);
case 'shell.started':
return this.onShellStarted(event);
case 'shell.output':
return this.onShellOutput(event);
case 'shell.completed':
return this.onShellCompleted(event);
case 'subagent.spawned':
return this.onSubagentSpawned(event);
case 'subagent.completed':
case 'subagent.failed':
case 'subagent.cancelled':
case 'subagent.suspended':
return this.onSubagentRun(event);
case 'goal.updated':
return this.onGoalUpdated(event);
case 'agent.status.updated':
return this.onAgentStatusUpdated(event);
case 'prompt.submitted':
return this.onPromptSubmitted(event);
case 'prompt.queued':
return this.onPromptQueued(event);
case 'prompt.started':
return this.onPromptStarted(event);
case 'prompt.completed':
return this.onPromptCompleted(event);
case 'prompt.aborted':
return this.onPromptAborted(event);
case 'prompt.steered':
return this.onPromptSteered(event);
case 'turn.steer':
return this.onTurnSteered(event);
case 'hook.result':
return [this.systemOp('hook', hookPayload(event), event.time)];
case 'skill.activated':
case 'plugin_command.activated':
return [];
case 'compaction.started':
case 'compaction.blocked':
case 'compaction.cancelled':
return [];
case 'compaction.completed': {
const result = event.result;
const text =
result.summary.length > 0 ? result.summary : result.contextSummary;
return [
this.systemOp(
'compaction',
{ phase: 'completed', text: text !== undefined && text.length > 0 ? text : undefined },
event.time,
),
];
}
case 'context.spliced':
return this.onContextSpliced(event);
case 'context.undone':
return this.onContextUndone(event);
case 'error':
return [
this.systemOp(
'notice',
{ level: 'error', message: event.message, ...restOf(event) },
event.time,
),
];
case 'warning':
return [
this.systemOp(
'notice',
{ level: 'warning', message: event.message, code: event.code },
event.time,
),
];
case 'cron.fired':
case 'permission.approval.requested':
case 'permission.approval.resolved':
case 'subagent.started':
return [];
case 'task.notified':
return this.onTaskNotified(event);
default: {
const type = (event as { type: string }).type;
if (PROJECTION_IGNORED_EVENT_TYPES.has(type)) return [];
this.hooks?.onUnknownEvent?.(type);
return [];
}
}
}
seedActiveTurn(info: {
turnId: number;
promptId?: string;
userMessageId?: string;
origin?: TurnOrigin;
anchor?: boolean;
}): void {
const turnId = turnIdOf(info.turnId);
this.noteTurnId(info.turnId);
if (info.anchor === true) this.anchorTurnOrdinals.add(info.turnId);
this.currentTurn = {
turnId,
ordinal: info.turnId,
status: 'running',
origin: info.origin ?? { kind: 'other' },
anchor: info.anchor === true,
promptId: info.promptId,
userMessageId:
info.promptId === undefined
? undefined
: (info.userMessageId ?? turnUserMessageIdOf(turnId)),
openingSteerDeduped: false,
};
this.turns.set(turnId, this.currentTurn);
this.timelineIds.push(turnId);
}
seedTask(info: AgentTaskInfo): ServerMessage[] {
if (info.status !== 'running') return [];
const agentInfo = agentInfoOf(info);
const kind = mapTaskKind(info.kind);
const task = this.upsertTask(info.taskId, (prev) => ({
taskId: info.taskId,
kind,
status: 'running',
detached: info.detached ?? prev?.detached ?? kind !== 'shell',
description: info.description,
childAgentId: agentInfo?.agentId ?? prev?.childAgentId,
outputTail: prev?.outputTail ?? '',
startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt),
model: agentInfo?.model ?? prev?.model,
thinkingEffort: agentInfo?.thinkingEffort ?? prev?.thinkingEffort,
}));
const childAgentId = agentInfo?.agentId;
if (info.kind === 'agent' && typeof childAgentId === 'string' && childAgentId.length > 0) {
this.subagentTaskIds.set(childAgentId, info.taskId);
}
return [this.taskOp(task)];
}
seedTodo(
items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[],
): ServerMessage[] {
if (items.length === 0) return [];
this.todoItems = items.map((item) => ({ title: item.title, status: item.status }));
this.todoUpdatedAt = undefined;
return [this.todoOp()];
}
taskOutputUpdated(taskId: string, outputTail: string): ServerMessage[] {
const task = this.tasks.get(taskId);
if (task === undefined || task.outputTail === outputTail) return [];
task.outputTail = outputTail;
return [this.taskOp(task)];
}
seedModes(modes: { planMode?: boolean; swarmMode?: boolean }): void {
if (modes.planMode !== undefined) this.planMode = modes.planMode;
if (modes.swarmMode !== undefined) this.swarmMode = modes.swarmMode;
}
todoChanged(
items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[],
): ServerMessage[] {
this.todoItems = items.map((item) => ({ title: item.title, status: item.status }));
this.todoUpdatedAt = nowIso();
return [this.todoOp()];
}
interactionRequested(interaction: ProjectorInteraction): ServerMessage[] {
const payload = interaction.payload as Record<string, unknown> | null;
const toolCallId =
typeof payload?.['toolCallId'] === 'string' ? payload['toolCallId'] : undefined;
const record: InteractionRecord = {
interactionId: interaction.id,
kind: interaction.kind,
status: 'pending',
toolCallId,
request: this.wireInteractionRequest(interaction),
};
this.interactions.set(interaction.id, record);
const ops: ServerMessage[] = [this.interactionOp(record)];
if (toolCallId !== undefined) {
const tool = this.tools.get(toolCallId);
if (tool !== undefined && tool.approvalId !== interaction.id) {
tool.approvalId = interaction.id;
ops.push(this.toolOp(tool));
}
}
return ops;
}
interactionResolved(id: string, response: unknown): ServerMessage[] {
const record = this.interactions.get(id);
if (record === undefined) return [];
record.status = mapInteractionEndStatus(record.kind, response);
record.response = this.wireInteractionResponse(record, response);
return [this.interactionOp(record)];
}
recoveryMessages(): ServerMessage[] {
const ops: ServerMessage[] = [];
const turn = this.currentTurn;
if (turn !== undefined && turn.status === 'running') {
ops.push(this.turnOp(turn));
const step = this.currentStep;
const replayStepId =
step !== undefined && step.turnId === turn.turnId ? step.stepId : undefined;
if (step !== undefined && replayStepId !== undefined) {
ops.push(this.stepOp(step));
for (const record of this.texts.values()) {
if (record.stepId === replayStepId) ops.push(this.textOp(record));
}
}
for (const tool of this.tools.values()) {
if (tool.turnId !== turn.turnId) continue;
if (tool.status === 'running' || tool.stepId === replayStepId) ops.push(this.toolOp(tool));
}
}
for (const record of this.interactions.values()) {
if (record.status === 'pending') ops.push(this.interactionOp(record));
}
for (const task of this.tasks.values()) {
if (task.status === 'running') ops.push(this.taskOp(task));
}
for (const user of this.users.values()) {
if (user.status === 'unread') ops.push(this.userOp(user));
}
if (this.todoItems !== undefined) ops.push(this.todoOp());
return ops;
}
notifyContextCleared(): ServerMessage[] {
this.cancelPendingClearTimer();
this.pendingFullCut = false;
return this.applyClear();
}
applyTimelineSeed(seed: {
timelineIds: readonly string[];
systemCounts: ReadonlyMap<string, number>;
anchorTurnOrdinals: readonly number[];
nextTurnId: number;
}): void {
if (this.timelineRewriteCount > 0) return;
const existing = new Set(this.timelineIds);
this.timelineIds.unshift(...seed.timelineIds.filter((id) => !existing.has(id)));
for (const [subtype, count] of seed.systemCounts) this.sysIds.seed(subtype, count);
for (const ordinal of seed.anchorTurnOrdinals) this.anchorTurnOrdinals.add(ordinal);
this.nextTurnIdHint = Math.max(this.nextTurnIdHint, seed.nextTurnId);
}
dispose(): void {
this.cancelPendingClearTimer();
}
private noteTurnId(turnId: number): void {
this.nextTurnIdHint = Math.max(this.nextTurnIdHint, turnId + 1);
}
takeEndedTurnOrdinals(): number[] {
return this.endedTurnOrdinals.splice(0);
}
healTurn(ordinal: number, fold: WireTurnFold): ServerMessage[] {
const turnId = turnIdOf(ordinal);
const held = this.turns.get(turnId);
if (held?.status !== 'completed') return [];
const ops: ServerMessage[] = [];
const stepOrdinals = new Set<number>([...fold.steps.keys(), ...fold.texts.keys()]);
for (const wireTool of fold.tools.values()) stepOrdinals.add(wireTool.step);
for (const stepOrdinal of [...stepOrdinals].toSorted((a, b) => a - b)) {
const wireStep = fold.steps.get(stepOrdinal);
const stepId = stepIdOf(turnId, stepOrdinal);
const live = this.steps.get(stepId);
if (live === undefined) {
const step: StepRecord = {
stepId,
turnId,
ordinal: stepOrdinal,
status: wireStep?.status ?? 'interrupted',
endedAt: wireStep?.endedAt,
usage: wireStep?.usage,
finishReason: wireStep?.finishReason,
timing: wireStep?.timing,
endReason: wireStep?.endReason,
endMessage: wireStep?.endMessage,
};
this.steps.set(stepId, step);
this.stepOrdinals.set(turnId, Math.max(this.stepOrdinals.get(turnId) ?? 0, stepOrdinal));
ops.push(this.stepOp(step));
} else if (live.status === 'running' && wireStep !== undefined) {
live.status = wireStep.status;
live.endedAt = wireStep.endedAt;
live.usage = live.usage ?? wireStep.usage;
live.finishReason = live.finishReason ?? wireStep.finishReason;
live.timing = live.timing ?? wireStep.timing;
live.endReason = live.endReason ?? wireStep.endReason;
live.endMessage = live.endMessage ?? wireStep.endMessage;
ops.push(this.stepOp(live));
}
const wireTexts = fold.texts.get(stepOrdinal);
if (wireTexts !== undefined) {
ops.push(...this.healStepTexts(stepId, turnId, wireTexts));
}
}
for (const [toolCallId, wireTool] of fold.tools) {
const live = this.tools.get(toolCallId);
const stepId = stepIdOf(turnId, wireTool.step);
if (live === undefined) {
const tool: ToolRecord = {
toolCallId,
turnId,
stepId,
name: wireTool.name,
status: wireTool.isError === true ? 'error' : 'done',
input: parseToolArgs(wireTool.args),
inputText: typeof wireTool.args === 'string' ? wireTool.args : undefined,
output: wireTool.output,
error:
wireTool.isError === true && typeof wireTool.output === 'string'
? wireTool.output
: undefined,
agentRefs: [],
};
this.tools.set(toolCallId, tool);
ops.push(this.toolOp(tool));
continue;
}
const liveHasOutcome =
live.output !== undefined || live.error !== undefined || live.status !== 'running';
const wireHasOutcome = wireTool.output !== undefined || wireTool.isError === true;
if (liveHasOutcome || !wireHasOutcome) continue;
live.status = wireTool.isError === true ? 'error' : 'done';
live.output = wireTool.output;
live.error =
wireTool.isError === true && typeof wireTool.output === 'string'
? wireTool.output
: undefined;
ops.push(this.toolOp(live));
}
this.dropTurnDetails(turnId);
return ops;
}
inFlight(): { turn_id: string; step_id: string } | undefined {
const turn = this.currentTurn;
const step = this.currentStep;
if (turn === undefined || step === undefined) return undefined;
if (turn.status !== 'running' || step.turnId !== turn.turnId) return undefined;
return { turn_id: turn.turnId, step_id: step.stepId };
}
private healStepTexts(
stepId: string,
turnId: string,
wireTexts: { assistant: string; thinking: string; first: 'assistant' | 'thinking' },
): ServerMessage[] {
const ops: ServerMessage[] = [];
const kinds: readonly ('assistant' | 'thinking')[] =
wireTexts.first === 'thinking' ? ['thinking', 'assistant'] : ['assistant', 'thinking'];
for (const kind of kinds) {
const wireText = kind === 'assistant' ? wireTexts.assistant : wireTexts.thinking;
const liveId = this.stepTextIds.get(stepId)?.[kind];
const live = liveId === undefined ? undefined : this.texts.get(liveId);
if (live === undefined) {
if (wireText.length === 0) continue;
const record = this.createTextRecord(stepId, turnId, kind);
record.text = wireText;
record.status = 'completed';
ops.push(this.textOp(record));
continue;
}
if (wireText.length > live.text.length) {
live.text = wireText;
live.status = 'completed';
ops.push(this.textOp(live));
}
}
return ops;
}
private onTurnStarted(event: {
time: number;
turnId: number;
promptId?: string;
origin: unknown;
prompt?: string;
promptAttachments?: readonly unknown[];
}): ServerMessage[] {
const ops = this.settlePendingClear();
if (this.currentTurn !== undefined && this.currentTurn.status === 'running') {
ops.push(...this.finalizeTurn(this.currentTurn, event.time));
}
const turnId = turnIdOf(event.turnId);
this.noteTurnId(event.turnId);
this.serverUserSeq = this.phantomUserSeq;
this.phantomUserSeq = 0;
const origin = this.mapTurnOrigin(event.origin);
const attachments = event.promptAttachments ?? [];
const attachmentIds = attachments.map((_, index) => attachmentIdOf(turnId, index + 1));
this.attachmentSeq = attachmentIds.length;
const promptRecord = event.promptId === undefined ? undefined : this.prompts.get(event.promptId);
const promptText =
event.prompt ??
(promptRecord === undefined ? undefined : promptTextOf(promptRecord.content));
const wantsUser = wantsUserMessage(event.origin, promptText);
const anchor = isUndoAnchorOrigin(event.origin);
if (anchor) this.anchorTurnOrdinals.add(event.turnId);
const turn: TurnRecord = {
turnId,
ordinal: event.turnId,
status: 'running',
origin,
anchor,
promptId: event.promptId,
userMessageId: wantsUser
? (promptRecord?.userMessageId ?? turnUserMessageIdOf(turnId))
: undefined,
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
openingKey: { text: promptText ?? '', attachments: attachmentIds.length },
openingSteerDeduped: false,
startedAt: epochMsToIso(event.time),
};
this.currentTurn = turn;
this.turns.set(turnId, turn);
this.timelineIds.push(turnId);
this.currentStep = undefined;
this.openText = undefined;
this.openThinking = undefined;
ops.push(this.turnOp(turn));
if (wantsUser && turn.userMessageId !== undefined) {
const user: UserRecord = {
messageId: turn.userMessageId,
turnId,
text:
promptRecord === undefined
? textPartsOf(promptText ?? '')
: wireContentParts(promptRecord.content),
status: 'read',
timestamp: event.time,
origin: userOriginOf(event.origin),
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
skillActivations: skillActivationsOf(event.origin),
};
this.users.set(user.messageId, user);
ops.push(this.userOp(user));
}
return ops;
}
private onTurnEnded(event: {
time: number;
turnId: number;
reason: 'completed' | 'cancelled' | 'failed' | 'blocked';
durationMs?: number;
interruptReason?: string;
}): ServerMessage[] {
const ops = this.settlePendingClear();
const turnId = turnIdOf(event.turnId);
const turn = this.currentTurn?.turnId === turnId ? this.currentTurn : this.turns.get(turnId);
if (turn === undefined) return ops;
ops.push(...this.finalizeTurn(turn, event.time, event.reason, event.durationMs));
this.currentStep = undefined;
if (this.currentTurn?.turnId === turnId) this.currentTurn = undefined;
this.endedTurnOrdinals.push(event.turnId);
if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') {
ops.push(
this.systemOp(
'interruption',
{ turn_id: turnId, reason: event.interruptReason },
event.time,
),
);
}
return ops;
}
private finalizeTurn(
turn: TurnRecord,
time: number,
reason?: 'completed' | 'cancelled' | 'failed' | 'blocked',
durationMs?: number,
): ServerMessage[] {
const ops = this.flushOpenTexts();
const turnId = turn.turnId;
if (this.currentStep !== undefined && this.currentStep.turnId === turnId) {
const step = this.currentStep;
if (step.status === 'running') {
step.status = reason === 'failed' || reason === 'blocked' ? 'failed' : 'interrupted';
step.endedAt = epochMsToIso(time);
ops.push(this.stepOp(step));
}
}
turn.status = 'completed';
turn.endedAt = epochMsToIso(time);
turn.durationMs = durationMs;
turn.usage = this.takeTurnUsage(turnId);
ops.push(this.turnOp(turn));
return ops;
}
private takeTurnUsage(turnId: string): StepUsage | undefined {
const usages = this.stepUsageByTurn.get(turnId);
this.stepUsageByTurn.delete(turnId);
if (usages === undefined || usages.length === 0) return undefined;
let inputOther = 0;
let output = 0;
let inputCacheRead = 0;
let inputCacheCreation = 0;
for (const usage of usages) {
inputOther += usage.input_other;
output += usage.output;
inputCacheRead += usage.input_cache_read;
inputCacheCreation += usage.input_cache_creation;
}
return {
input_other: inputOther,
output,
input_cache_read: inputCacheRead,
input_cache_creation: inputCacheCreation,
};
}
private onStepStarted(event: { time: number; turnId: number; step: number }): ServerMessage[] {
const ops = this.settlePendingClear();
const turnId = turnIdOf(event.turnId);
if (this.currentStep !== undefined && this.currentStep.status === 'running') {
ops.push(...this.flushOpenTexts());
this.currentStep.status = 'completed';
this.currentStep.endedAt = epochMsToIso(event.time);
ops.push(this.stepOp(this.currentStep));
}
const stepId = stepIdOf(turnId, event.step);
this.stepOrdinals.set(turnId, event.step);
const step: StepRecord = {
stepId,
turnId,
ordinal: event.step,
status: 'running',
startedAt: epochMsToIso(event.time),
};
this.currentStep = step;
this.steps.set(stepId, step);
this.openText = undefined;
this.openThinking = undefined;
ops.push(this.stepOp(step));
return ops;
}
private onStepCompleted(event: {
time: number;
turnId: number;
step: number;
usage?: TokenUsage;
finishReason?: string;
rawFinishReason?: string;
providerFinishReason?: string;
llmFirstTokenLatencyMs?: number;
llmStreamDurationMs?: number;
}): ServerMessage[] {
const ops = this.settlePendingClear();
ops.push(...this.flushOpenTexts());
const turnId = turnIdOf(event.turnId);
const stepId = stepIdOf(turnId, event.step);
const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId);
const usage = event.usage === undefined ? undefined : toSnakeUsage(event.usage);
if (usage !== undefined) {
const usages = this.stepUsageByTurn.get(turnId) ?? [];
usages.push(usage);
this.stepUsageByTurn.set(turnId, usages);
}
const step: StepRecord = {
stepId,
turnId,
ordinal: event.step,
status: 'completed',
startedAt: prev?.startedAt,
endedAt: epochMsToIso(event.time),
usage,
finishReason: event.finishReason ?? event.rawFinishReason ?? event.providerFinishReason,
timing: timingOf(event),
};
this.currentStep = step;
this.steps.set(stepId, step);
ops.push(this.stepOp(step));
return ops;
}
private onStepInterrupted(event: {
time: number;
turnId: number;
step: number;
reason: string;
message?: string;
}): ServerMessage[] {
const ops = this.settlePendingClear();
ops.push(...this.flushOpenTexts());
const turnId = turnIdOf(event.turnId);
const stepId = stepIdOf(turnId, event.step);
const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId);
const step: StepRecord = {
stepId,
turnId,
ordinal: event.step,
status: 'interrupted',
startedAt: prev?.startedAt,
endedAt: epochMsToIso(event.time),
endReason: event.reason,
endMessage: event.message,
};
this.currentStep = step;
this.steps.set(stepId, step);
ops.push(this.stepOp(step));
return ops;
}
private onStepRetrying(event: {
turnId: number;
step: number;
failedAttempt: number;
nextAttempt: number;
maxAttempts: number;
delayMs: number;
errorName: string;
errorMessage: string;
statusCode?: number;
}): ServerMessage[] {
const turnId = turnIdOf(event.turnId);
const stepId = stepIdOf(turnId, event.step);
const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId);
const step: StepRecord = {
stepId,
turnId,
ordinal: event.step,
status: 'running',
startedAt: prev?.startedAt,
retry: {
failed_attempt: event.failedAttempt,
next_attempt: event.nextAttempt,
max_attempts: event.maxAttempts,
delay_ms: event.delayMs,
error_name: event.errorName,
error_message: event.errorMessage,
status_code: event.statusCode,
},
};
this.currentStep = step;
this.steps.set(stepId, step);
return [this.stepOp(step)];
}
private onTextDelta(
event: { time: number; turnId: number; delta: string },
kind: 'assistant' | 'thinking',
): ServerMessage[] {
if (kind === 'thinking' && event.delta.length === 0) return [];
const ops = this.settlePendingClear();
const turnId = turnIdOf(event.turnId);
this.ensureTurn(turnId, event.time, ops);
const step = this.ensureStep(turnId, event.time, ops);
let open = kind === 'assistant' ? this.openText : this.openThinking;
if (open === undefined || open.stepId !== step.stepId) {
open = this.createTextRecord(step.stepId, turnId, kind);
if (kind === 'assistant') this.openText = open;
else this.openThinking = open;
ops.push(this.textOp(open));
}
open.text += event.delta;
ops.push(this.textDeltaOp(open, event.delta));
return ops;
}
private flushOpenTexts(): ServerMessage[] {
const ops: ServerMessage[] = [];
for (const open of [this.openText, this.openThinking]) {
if (open === undefined) continue;
open.status = 'completed';
ops.push(this.textOp(open));
}
this.openText = undefined;
this.openThinking = undefined;
return ops;
}
private ensureTurn(turnId: string, time: number, ops: ServerMessage[]): TurnRecord {
if (this.currentTurn !== undefined && this.currentTurn.turnId === turnId) {
return this.currentTurn;
}
const ordinal = turnOrdinalOf(turnId) ?? 0;
this.noteTurnId(ordinal);
const turn: TurnRecord = {
turnId,
ordinal,
status: 'running',
origin: { kind: 'other' },
anchor: false,
openingSteerDeduped: false,
startedAt: epochMsToIso(time),
};
this.currentTurn = turn;
this.turns.set(turnId, turn);
this.timelineIds.push(turnId);
ops.push(this.turnOp(turn));
return turn;
}
private ensureStep(turnId: string, time: number, ops: ServerMessage[]): StepRecord {
if (this.currentStep !== undefined && this.currentStep.turnId === turnId) {
return this.currentStep;
}
const ordinal = this.lookups?.stepOrdinal?.(turnId) ?? this.stepOrdinals.get(turnId) ?? 1;
const step: StepRecord = {
stepId: stepIdOf(turnId, ordinal),
turnId,
ordinal,
status: 'running',
startedAt: epochMsToIso(time),
};
this.stepOrdinals.set(turnId, ordinal);
this.currentStep = step;
this.steps.set(step.stepId, step);
ops.push(this.stepOp(step));
return step;
}
private onToolCallDelta(event: {
time: number;
turnId: number;
toolCallId: string;
name?: string;
argumentsPart?: string;
}): ServerMessage[] {
const ops = this.settlePendingClear();
const prev = this.tools.get(event.toolCallId);
if (prev !== undefined) {
prev.inputText = (prev.inputText ?? '') + (event.argumentsPart ?? '');
ops.push(this.toolDeltaOp(event.toolCallId, event.argumentsPart ?? ''));
return ops;
}
const turnId = turnIdOf(event.turnId);
this.ensureTurn(turnId, event.time, ops);
const step = this.ensureStep(turnId, event.time, ops);
const tool: ToolRecord = {
toolCallId: event.toolCallId,
turnId,
stepId: step.stepId,
name: event.name ?? '',
status: 'running',
inputText: event.argumentsPart ?? '',
agentRefs: [],
startedAt: epochMsToIso(event.time),
};
this.tools.set(event.toolCallId, tool);
ops.push(this.toolOp(tool));
if ((event.argumentsPart ?? '').length > 0) {
ops.push(this.toolDeltaOp(event.toolCallId, event.argumentsPart ?? ''));
}
return ops;
}
private onToolProgress(event: {
toolCallId: string;
update: {
kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom';
text?: string;
percent?: number;
customKind?: string;
customData?: unknown;
};
}): ServerMessage[] {
const tool = this.tools.get(event.toolCallId);
if (tool === undefined) return [];
tool.progress = {
kind: event.update.kind,
text: event.update.text,
percent: event.update.percent,
custom_kind: event.update.customKind,
custom_data: event.update.customData,
};
return [
{
type: 'tool.progress',
...this.base(),
tool_call_id: event.toolCallId,
progress: tool.progress,
},
];
}
private onToolCallStarted(event: {
time: number;
turnId: number;
toolCallId: string;
name: string;
args: unknown;
display?: unknown;
}): ServerMessage[] {
const ops = this.settlePendingClear();
const turnId = turnIdOf(event.turnId);
this.ensureTurn(turnId, event.time, ops);
const step = this.ensureStep(turnId, event.time, ops);
const prev = this.tools.get(event.toolCallId);
const input = parseToolArgs(event.args);
const todoItems = event.name === 'TodoList' ? todoWriteItems(input) : undefined;
const tool: ToolRecord = {
toolCallId: event.toolCallId,
turnId,
stepId: step.stepId,
name: event.name,
status: 'running',
input,
inputText: prev?.inputText ?? (typeof event.args === 'string' ? event.args : undefined),
display: event.display,
todoId: todoItems !== undefined ? TODO_ENTITY_ID : undefined,
progress: prev?.progress,
agentRefs: prev?.agentRefs ?? [],
startedAt: prev?.startedAt ?? epochMsToIso(event.time),
};
this.tools.set(event.toolCallId, tool);
ops.push(this.toolOp(tool));
return ops;
}
private onToolResult(event: {
time: number;
turnId: number;
toolCallId: string;
output: unknown;
isError?: boolean;
}): ServerMessage[] {
const ops = this.settlePendingClear();
let tool = this.tools.get(event.toolCallId);
if (tool === undefined) {
const turnId = turnIdOf(event.turnId);
this.ensureTurn(turnId, event.time, ops);
const step = this.ensureStep(turnId, event.time, ops);
tool = {
toolCallId: event.toolCallId,
turnId,
stepId: step.stepId,
name: '',
status: 'running',
agentRefs: [],
};
this.tools.set(event.toolCallId, tool);
}
const isError = event.isError === true;
tool.status = isError ? 'error' : 'done';
tool.output = event.output;
tool.error = isError && typeof event.output === 'string' ? event.output : undefined;
ops.push(this.toolOp(tool));
return ops;
}
private onTaskLifecycle(event: {
type: 'task.started' | 'task.terminated';
time: number;
info: AgentTaskInfo;
}): ServerMessage[] {
const ops = this.settlePendingClear();
const { info } = event;
const agentInfo = agentInfoOf(info);
const parentTool =
agentInfo?.parentToolCallId === undefined
? undefined
: this.tools.get(agentInfo.parentToolCallId);
const task = this.upsertTask(info.taskId, (prev) => ({
taskId: info.taskId,
kind: mapTaskKind(info.kind),
status: info.status,
detached: info.detached ?? prev?.detached ?? true,
description: info.description,
childAgentId: agentInfo?.agentId ?? prev?.childAgentId,
outputTail: prev?.outputTail ?? '',
startedAt: prev?.startedAt ?? parentTool?.startedAt ?? epochMsToIso(info.startedAt),
endedAt: info.endedAt === null ? prev?.endedAt : epochMsToIso(info.endedAt),
resultSummary: prev?.resultSummary,
usage: prev?.usage,
error: prev?.error,
stateReason: info.stopReason ?? prev?.stateReason,
model: agentInfo?.model ?? prev?.model,
thinkingEffort: agentInfo?.thinkingEffort ?? prev?.thinkingEffort,
}));
if (event.type === 'task.started') {
const childAgentId = agentInfo?.agentId;
if (info.kind === 'agent' && typeof childAgentId === 'string' && childAgentId.length > 0) {
this.subagentTaskIds.set(childAgentId, info.taskId);
if (parentTool !== undefined && parentTool.taskId !== info.taskId) {
parentTool.taskId = info.taskId;
ops.push(this.toolOp(parentTool));
}
}
}
ops.push(this.taskOp(task));
return ops;
}
private onShellStarted(event: {
time: number;
commandId: string;
taskId: string;
}): ServerMessage[] {
const ops = this.settlePendingClear();
this.shellTasks.set(event.commandId, event.taskId);
const task = this.upsertTask(event.taskId, (prev) => ({
taskId: event.taskId,
kind: 'shell',
status: 'running',
detached: prev?.detached ?? false,
description: prev?.description,
outputTail: prev?.outputTail ?? '',
startedAt: prev?.startedAt ?? epochMsToIso(event.time),
}));
ops.push(this.taskOp(task));
return ops;
}
private shellTaskId(event: { commandId: string; taskId?: string }): string {
const taskId =
this.shellTasks.get(event.commandId) ?? event.taskId ?? `shell-${event.commandId}`;
this.shellTasks.set(event.commandId, taskId);
return taskId;
}
private onShellOutput(event: {
time: number;
commandId: string;
taskId?: string;
update: { kind: string; text?: string };
}): ServerMessage[] {
const text = event.update.text;
if (typeof text !== 'string' || text.length === 0) return [];
const ops = this.settlePendingClear();
const taskId = this.shellTaskId(event);
const task = this.upsertTask(taskId, (prev) => ({
taskId,
kind: prev?.kind ?? 'shell',
status: 'running',
detached: prev?.detached ?? false,
description: prev?.description,
outputTail: tailWindow((prev?.outputTail ?? '') + text),
startedAt: prev?.startedAt ?? epochMsToIso(event.time),
}));
ops.push(this.taskOp(task));
return ops;
}
private onShellCompleted(event: {
time: number;
commandId: string;
taskId?: string;
isError: boolean;
}): ServerMessage[] {
const ops = this.settlePendingClear();
const taskId = this.shellTaskId(event);
const task = this.upsertTask(taskId, (prev) => ({
taskId,
kind: prev?.kind ?? 'shell',
status: event.isError ? 'failed' : 'completed',
detached: prev?.detached ?? false,
description: prev?.description,
outputTail: prev?.outputTail ?? '',
startedAt: prev?.startedAt ?? epochMsToIso(event.time),
endedAt: epochMsToIso(event.time),
}));
ops.push(this.taskOp(task));
return ops;
}
private upsertTask(
taskId: string,
build: (prev: TaskRecord | undefined) => TaskRecord,
): TaskRecord {
const task = build(this.tasks.get(taskId));
this.tasks.set(taskId, task);
return task;
}
private onSubagentSpawned(event: {
time: number;
subagentId: string;
parentToolCallId: string;
description?: string;
swarmIndex?: number;
runInBackground: boolean;
taskId?: string;
model?: string;
thinkingEffort?: string;
}): ServerMessage[] {
const ops = this.settlePendingClear();
const tool = this.tools.get(event.parentToolCallId);
if (tool !== undefined) {
const ref: ToolCallAgentRef = {
agent_id: event.subagentId,
role: event.swarmIndex !== undefined ? 'member' : 'child',
};
tool.agentRefs = [...tool.agentRefs, ref];
ops.push(this.toolOp(tool));
}
const taskId = event.taskId;
if (taskId === undefined) return ops;
this.subagentTaskIds.set(event.subagentId, taskId);
if (tool !== undefined && tool.taskId !== taskId) {
tool.taskId = taskId;
ops.push(this.toolOp(tool));
}
const task = this.upsertTask(taskId, (prev) => ({
taskId,
kind: 'subagent',
status: 'running',
detached: event.runInBackground,
description: event.description ?? prev?.description,
childAgentId: event.subagentId,
outputTail: prev?.outputTail ?? '',
startedAt: prev?.startedAt ?? tool?.startedAt ?? epochMsToIso(event.time),
model: event.model ?? prev?.model,
thinkingEffort: event.thinkingEffort ?? prev?.thinkingEffort,
}));
ops.push(this.taskOp(task));
return ops;
}
private onSubagentRun(event: {
type: 'subagent.completed' | 'subagent.failed' | 'subagent.cancelled' | 'subagent.suspended';
time: number;
subagentId: string;
resultSummary?: string;
usage?: TokenUsage;
error?: string;
reason?: string;
}): ServerMessage[] {
const ops = this.settlePendingClear();
const taskKey = this.subagentTaskIds.get(event.subagentId) ?? event.subagentId;
const existing = this.tasks.get(taskKey);
if (existing === undefined) return ops;
const terminal = event.type !== 'subagent.suspended';
existing.status =
event.type === 'subagent.completed'
? 'completed'
: event.type === 'subagent.failed'
? 'failed'
: event.type === 'subagent.cancelled'
? 'killed'
: 'running';
if (terminal) existing.endedAt = epochMsToIso(event.time);
existing.resultSummary = event.resultSummary ?? existing.resultSummary;
existing.usage = event.usage === undefined ? existing.usage : toSnakeUsage(event.usage);
existing.error = event.error ?? existing.error;
existing.stateReason = event.reason ?? existing.stateReason;
ops.push(this.taskOp(existing));
return ops;
}
private onGoalUpdated(event: {
time: number;
snapshot: {
objective: string;
status: 'active' | 'paused' | 'blocked' | 'complete';
completionCriterion?: string;
tokensUsed: number;
budget: { tokenBudget: number | null };
} | null;
}): ServerMessage[] {
const snapshot = event.snapshot;
const payload =
snapshot === null
? undefined
: {
objective: snapshot.objective,
status: snapshot.status,
completion_criterion: snapshot.completionCriterion,
budget_used: snapshot.tokensUsed,
budget_limit: snapshot.budget.tokenBudget ?? undefined,
};
return [this.systemOp('goal', payload, event.time)];
}
private onAgentStatusUpdated(event: {
time: number;
planMode?: boolean;
swarmMode?: boolean;
}): ServerMessage[] {
const ops: ServerMessage[] = [];
if (event.planMode !== undefined && event.planMode !== this.planMode) {
this.planMode = event.planMode;
if (event.planMode) {
ops.push(this.systemOp('plan.enter', undefined, event.time));
} else if (this.planExitApproved()) {
ops.push(this.systemOp('plan.exit', undefined, event.time));
}
}
if (event.swarmMode !== undefined && event.swarmMode !== this.swarmMode) {
this.swarmMode = event.swarmMode;
ops.push(
this.systemOp(event.swarmMode ? 'swarm.enter' : 'swarm.exit', undefined, event.time),
);
}
return ops;
}
private planExitApproved(): boolean {
let latest: ToolRecord | undefined;
for (const tool of this.tools.values()) {
if (tool.name === 'ExitPlanMode') latest = tool;
}
if (latest?.approvalId === undefined) return false;
return this.interactions.get(latest.approvalId)?.status === 'approved';
}
private onPlanRevision(event: {
time: number;
id: string;
version: number;
key: string;
sha256: string;
bytes: number;
}): ServerMessage[] {
const path = this.lookups?.resolvePlanRevisionKey?.(event.key) ?? event.key;
return [
this.systemOp(
'plan.revision',
{ id: event.id, version: event.version, path, sha256: event.sha256, bytes: event.bytes },
event.time,
),
];
}
private onPromptSubmitted(event: {
promptId: string;
userMessageId: string;
status: 'running' | 'queued';
content: readonly ContentPart[];
createdAt: string;
}): ServerMessage[] {
const prev = this.prompts.get(event.promptId);
this.prompts.set(event.promptId, {
promptId: event.promptId,
userMessageId: event.userMessageId,
content: event.content,
status: event.status,
createdAt: prev?.createdAt ?? event.createdAt,
});
return [];
}
private onPromptQueued(event: {
promptId: string;
content: readonly ContentPart[];
}): ServerMessage[] {
let prev = this.prompts.get(event.promptId);
if (prev === undefined) {
prev = {
promptId: event.promptId,
userMessageId: event.promptId,
content: event.content,
status: 'queued',
createdAt: nowIso(),
};
this.prompts.set(event.promptId, prev);
}
if (this.users.has(prev.userMessageId)) return [];
const user: UserRecord = {
messageId: prev.userMessageId,
text: wireContentParts(prev.content),
status: 'unread',
};
this.users.set(user.messageId, user);
return [this.userOp(user)];
}
private onPromptStarted(event: { promptId: string }): ServerMessage[] {
const prev = this.prompts.get(event.promptId);
if (prev === undefined) return [];
prev.status = 'running';
return [];
}
private onPromptCompleted(event: { promptId: string }): ServerMessage[] {
const prev = this.prompts.get(event.promptId);
if (prev === undefined) return [];
prev.status = 'completed';
return [];
}
private onPromptAborted(event: { promptId: string }): ServerMessage[] {
const prev = this.prompts.get(event.promptId);
if (prev === undefined) return [];
prev.status = 'aborted';
return [];
}
private onPromptSteered(event: {
activePromptId: string;
promptIds: string[];
content: readonly ContentPart[];
steeredAt: string;
}): ServerMessage[] {
for (const promptId of event.promptIds) {
const prev = this.prompts.get(promptId);
if (prev === undefined) continue;
prev.status = 'steered';
}
this.mergedSteers.push({ text: promptTextOf(event.content), promptIds: event.promptIds });
return [];
}
private onTurnSteered(event: {
time: number;
input: readonly ContentPart[];
origin: unknown;
}): ServerMessage[] {
const origin = event.origin as {
kind?: string;
skillActivations?: readonly { skillName: string; skillArgs?: string }[];
jobId?: string;
cron?: string;
trigger?: string;
};
const kind = origin.kind;
if (kind !== 'user' && kind !== 'skill_activation' && kind !== 'cron_job') return [];
if (kind === 'skill_activation' && origin.trigger !== 'user-slash') return [];
const ops = this.settlePendingClear();
const turn = this.currentTurn;
if (turn === undefined || turn.status !== 'running') return ops;
const skipBlocks = kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0;
const step = this.currentStep;
const stepStarted = step !== undefined && step.turnId === turn.turnId;
if (!stepStarted && !turn.openingSteerDeduped && turn.openingKey !== undefined) {
const key = steerKeyOf(event.input, skipBlocks);
if (key.text === turn.openingKey.text && key.attachments === turn.openingKey.attachments) {
turn.openingSteerDeduped = true;
return ops;
}
}
const matched =
kind === 'user' ? this.matchQueuedPrompt(event.input, skipBlocks) : undefined;
if (matched !== undefined) {
const text = promptTextOf(event.input.slice(skipBlocks));
this.mergedSteers = this.mergedSteers.filter((entry) => entry.text !== text);
const existing = this.users.get(matched);
if (existing !== undefined) {
if (existing.status === 'unread') {
existing.status = 'read';
existing.turnId = turn.turnId;
existing.timestamp = event.time;
ops.push(this.userOp(existing));
}
return ops;
}
ops.push(
this.steerUserMessage(turn, event.input, {
origin: userOriginOf(event.origin),
skillActivations: skillActivationsOf(event.origin),
skipBlocks,
at: event.time,
messageId: matched,
}),
);
return ops;
}
if (kind === 'user') {
const merged = this.matchMergedSteer(event.input, skipBlocks);
if (merged !== undefined) {
ops.push(...this.readSteeredUsers(merged, turn, event.time));
return ops;
}
}
ops.push(
this.steerUserMessage(turn, event.input, {
origin: userOriginOf(event.origin),
skillActivations: skillActivationsOf(event.origin),
skipBlocks,
at: event.time,
}),
);
return ops;
}
private matchQueuedPrompt(
input: readonly ContentPart[],
skipBlocks: number,
): string | undefined {
const text = promptTextOf(input.slice(skipBlocks));
let matched: string | undefined;
for (const prompt of this.prompts.values()) {
if (prompt.status !== 'queued' && prompt.status !== 'steered') continue;
if (promptTextOf(prompt.content) !== text) continue;
if (matched !== undefined) return undefined;
matched = prompt.userMessageId;
}
return matched;
}
private matchMergedSteer(
input: readonly ContentPart[],
skipBlocks: number,
): { promptIds: string[] } | undefined {
const text = promptTextOf(input.slice(skipBlocks));
const index = this.mergedSteers.findIndex((entry) => entry.text === text);
if (index < 0) return undefined;
const [entry] = this.mergedSteers.splice(index, 1);
return entry;
}
private readSteeredUsers(
merged: { promptIds: string[] },
turn: TurnRecord,
timestamp: number,
): ServerMessage[] {
const ops: ServerMessage[] = [];
for (const promptId of merged.promptIds) {
const prompt = this.prompts.get(promptId);
if (prompt === undefined) continue;
prompt.status = 'completed';
const user = this.users.get(prompt.userMessageId);
if (user === undefined || user.status !== 'unread') continue;
user.status = 'read';
user.turnId = turn.turnId;
user.timestamp = timestamp;
ops.push(this.userOp(user));
}
return ops;
}
private steerUserMessage(
turn: TurnRecord,
input: readonly ContentPart[],
opts: {
origin: UserMessageOrigin | undefined;
skillActivations: { skill_name: string; skill_args?: string }[] | undefined;
skipBlocks: number;
at: number;
text?: string;
messageId?: string;
},
): ServerMessage {
const messageId = opts.messageId ?? `${turn.turnId}.u${(this.serverUserSeq += 1)}`;
const attachmentIds: string[] = [];
for (const part of input.slice(opts.skipBlocks)) {
if (part.type === 'text') continue;
if (daemonFileRefFromPart(part) === undefined) continue;
this.attachmentSeq += 1;
attachmentIds.push(attachmentIdOf(turn.turnId, this.attachmentSeq));
}
const user: UserRecord = {
messageId,
turnId: turn.turnId,
text:
opts.text !== undefined
? textPartsOf(opts.text)
: wireContentParts(input.slice(opts.skipBlocks)),
status: 'read',
timestamp: opts.at,
origin: opts.origin,
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
skillActivations: opts.skillActivations,
};
this.users.set(messageId, user);
return this.userOp(user);
}
private onTaskNotified(event: {
time: number;
notificationType: string;
title: string;
body: string;
severity: string;
sourceKind: string;
sourceId: string;
}): ServerMessage[] {
const ops = this.settlePendingClear();
const notification: TaskNotificationPayload = {
title: event.title,
body: event.body,
severity: event.severity,
type: event.notificationType,
source_kind: event.sourceKind,
source_id: event.sourceId,
};
const origin = taskUserOriginOf(event.sourceId, notification);
if (origin === undefined) return ops;
const text = notificationTextOf(notification);
const turn = this.currentTurn;
if (
turn !== undefined &&
turn.status === 'running' &&
turn.origin.kind === 'task' &&
turn.origin.task_id === origin.task_id
) {
const messageId = turn.userMessageId ?? turnUserMessageIdOf(turn.turnId);
if (turn.userMessageId === undefined) {
turn.userMessageId = messageId;
ops.push(this.turnOp(turn));
}
const existing = this.users.get(messageId);
if (existing !== undefined) {
if (existing.origin?.kind !== 'task' || existing.origin.title === '') {
existing.text = textPartsOf(text);
existing.origin = origin;
ops.push(this.userOp(existing));
}
return ops;
}
const user: UserRecord = {
messageId,
turnId: turn.turnId,
text: textPartsOf(text),
status: 'read',
timestamp: event.time,
origin,
};
this.users.set(messageId, user);
ops.push(this.userOp(user));
return ops;
}
if (turn !== undefined && turn.status === 'running') {
ops.push(
this.steerUserMessage(turn, [], {
origin,
skillActivations: undefined,
skipBlocks: 0,
at: event.time,
text,
}),
);
return ops;
}
this.phantomUserSeq += 1;
const turnId = turnIdOf(this.nextTurnIdHint);
const user: UserRecord = {
messageId: `${turnId}.u${this.phantomUserSeq}`,
turnId,
text: textPartsOf(text),
status: 'read',
timestamp: event.time,
origin,
};
this.users.set(user.messageId, user);
ops.push(this.userOp(user));
return ops;
}
private onContextSpliced(event: {
start: number;
deleteCount: number;
messages: readonly unknown[];
}): ServerMessage[] {
if (event.start === 0 && event.deleteCount > 0 && event.messages.length === 0) {
this.pendingFullCut = true;
this.armPendingClearTimer();
}
return [];
}
private armPendingClearTimer(): void {
if (this.pendingClearTimer !== undefined) return;
this.pendingClearTimer = setTimeout(() => {
this.pendingClearTimer = undefined;
if (!this.pendingFullCut) return;
this.pendingFullCut = false;
this.hooks?.onDeferred?.(this.applyClear());
}, PENDING_CLEAR_SETTLE_MS);
this.pendingClearTimer.unref();
}
private cancelPendingClearTimer(): void {
if (this.pendingClearTimer === undefined) return;
clearTimeout(this.pendingClearTimer);
this.pendingClearTimer = undefined;
}
private settlePendingClear(): ServerMessage[] {
if (!this.pendingFullCut) return [];
this.cancelPendingClearTimer();
this.pendingFullCut = false;
return this.applyClear();
}
private applyClear(): ServerMessage[] {
this.timelineRewriteCount += 1;
const removed = [...this.timelineIds];
const op = this.systemOp('clear', { removed_ids: removed }, undefined);
this.turns.clear();
this.steps.clear();
this.texts.clear();
this.stepTextIds.clear();
this.stepTextSeqs.clear();
this.tools.clear();
this.users.clear();
this.stepOrdinals.clear();
this.stepUsageByTurn.clear();
this.timelineIds.length = 0;
this.currentTurn = undefined;
this.currentStep = undefined;
this.openText = undefined;
this.openThinking = undefined;
return [op];
}
private onContextUndone(event: {
time: number;
turns: number;
fromTurnId?: number;
}): ServerMessage[] {
this.cancelPendingClearTimer();
this.pendingFullCut = false;
const removed = this.removedIdsForUndo(event.turns, event.fromTurnId);
if (removed.length === 0) return [];
this.timelineRewriteCount += 1;
const op = this.systemOp('undo', { removed_ids: removed }, event.time);
for (const id of removed) {
if (turnOrdinalOf(id) === undefined) continue;
this.dropTurnDetails(id);
this.stepOrdinals.delete(id);
this.stepUsageByTurn.delete(id);
}
const firstRemoved = this.timelineIds.indexOf(removed[0]!);
if (firstRemoved >= 0) this.timelineIds.splice(firstRemoved);
return [op];
}
private removedIdsForUndo(turns: number, fromTurnId: number | undefined): string[] {
const cut = this.findUndoCutIndex(turns, fromTurnId);
if (cut === undefined) return [];
return this.timelineIds.slice(cut);
}
private findUndoCutIndex(turns: number, fromTurnId: number | undefined): number | undefined {
if (fromTurnId !== undefined) {
for (let i = 0; i < this.timelineIds.length; i++) {
const ordinal = turnOrdinalOf(this.timelineIds[i]!);
if (ordinal !== undefined && ordinal >= fromTurnId) return i;
}
return undefined;
}
let remaining = turns;
for (let i = this.timelineIds.length - 1; i >= 0; i--) {
const id = this.timelineIds[i]!;
if (isCompactionSystemId(id)) return undefined;
const ordinal = turnOrdinalOf(id);
if (ordinal === undefined) continue;
if (!this.anchorTurnOrdinals.has(ordinal)) continue;
remaining -= 1;
if (remaining === 0) return i;
}
return undefined;
}
private dropTurnDetails(turnId: string): void {
this.turns.delete(turnId);
for (const [stepId, step] of this.steps) {
if (step.turnId === turnId) this.steps.delete(stepId);
}
for (const [stepId, entry] of this.stepTextIds) {
if (!stepId.startsWith(`${turnId}.`)) continue;
if (entry.assistant !== undefined) this.texts.delete(entry.assistant);
if (entry.thinking !== undefined) this.texts.delete(entry.thinking);
this.stepTextIds.delete(stepId);
this.stepTextSeqs.delete(stepId);
}
for (const [toolCallId, tool] of this.tools) {
if (tool.turnId === turnId) this.tools.delete(toolCallId);
}
for (const [messageId, user] of this.users) {
if (user.turnId === turnId) this.users.delete(messageId);
}
}
private mapTurnOrigin(origin: unknown): TurnOrigin {
return toTurnOrigin(origin, this.agentId, this.subagentTaskIds);
}
private wireInteractionRequest(interaction: ProjectorInteraction): unknown {
return wireInteractionRequest(interaction.kind, interaction.payload);
}
private wireInteractionResponse(record: InteractionRecord, response: unknown): unknown {
return wireInteractionResponse(record.kind, record.request, response);
}
private createTextRecord(
stepId: string,
turnId: string,
kind: 'assistant' | 'thinking',
): TextRecord {
const seq = (this.stepTextSeqs.get(stepId) ?? 0) + 1;
this.stepTextSeqs.set(stepId, seq);
const record: TextRecord = {
messageId: textMessageIdOf(stepId, seq),
kind,
turnId,
stepId,
status: 'streaming',
text: '',
};
this.texts.set(record.messageId, record);
const entry = this.stepTextIds.get(stepId) ?? {};
entry[kind] = record.messageId;
this.stepTextIds.set(stepId, entry);
return record;
}
private base(): { session_id: string; agent_id: string; timestamp: number } {
return { session_id: this.sessionId, agent_id: this.agentId, timestamp: Date.now() };
}
private turnOp(turn: TurnRecord): TurnMessage {
return {
type: 'turn',
...this.base(),
turn_id: turn.turnId,
ordinal: turn.ordinal,
status: turn.status,
origin: turn.origin,
user_message_id: turn.userMessageId,
attachment_ids: turn.attachmentIds,
started_at: turn.startedAt,
ended_at: turn.endedAt,
usage: turn.usage === undefined ? undefined : turnUsageToWire(turn.usage),
duration_ms: turn.durationMs,
};
}
private stepOp(step: StepRecord): StepMessage {
return {
type: 'step',
...this.base(),
step_id: step.stepId,
turn_id: step.turnId,
ordinal: step.ordinal,
status: step.status,
started_at: step.startedAt,
ended_at: step.endedAt,
usage: step.usage,
finish_reason: step.finishReason,
timing: step.timing,
retry: step.retry,
end_reason: step.endReason,
end_message: step.endMessage,
};
}
private textOp(record: TextRecord): AssistantMessage | ThinkingMessage {
const base = {
...this.base(),
message_id: record.messageId,
turn_id: record.turnId,
step_id: record.stepId,
status: record.status,
text: record.text,
};
if (record.kind === 'assistant') return { type: 'assistant', ...base };
return { type: 'thinking', ...base };
}
private textDeltaOp(record: TextRecord, delta: string): ServerMessage {
if (record.kind === 'assistant') {
return {
type: 'assistant.delta',
...this.base(),
message_id: record.messageId,
text: delta,
};
}
return {
type: 'thinking.delta',
...this.base(),
message_id: record.messageId,
text: delta,
};
}
private toolOp(tool: ToolRecord): ToolCallMessage {
return {
type: 'tool_call',
...this.base(),
tool_call_id: tool.toolCallId,
turn_id: tool.turnId,
step_id: tool.stepId,
name: tool.name,
status: tool.status,
input: tool.input,
input_text: tool.inputText,
output: tool.output,
display: tool.display,
error: tool.error,
progress: tool.progress,
task_id: tool.taskId,
approval_id: tool.approvalId,
todo_id: tool.todoId,
agent_refs: tool.agentRefs.length > 0 ? tool.agentRefs : undefined,
};
}
private toolDeltaOp(toolCallId: string, inputText: string): ServerMessage {
return {
type: 'tool_call.delta',
...this.base(),
tool_call_id: toolCallId,
input_text: inputText,
};
}
private userOp(user: UserRecord): UserMessage {
return {
type: 'user',
session_id: this.sessionId,
agent_id: this.agentId,
message_id: user.messageId,
turn_id: user.turnId,
status: user.status,
timestamp: user.timestamp,
text: user.text,
attachment_ids: user.attachmentIds,
skill_activations: user.skillActivations,
origin: user.origin,
};
}
private taskOp(task: TaskRecord): TaskMessage {
return {
type: 'task',
...this.base(),
task_id: task.taskId,
kind: task.kind,
status: task.status,
detached: task.detached,
description: task.description,
child_agent_id: task.childAgentId,
output_tail: task.outputTail,
started_at: task.startedAt,
ended_at: task.endedAt,
result_summary: task.resultSummary,
error: task.error,
state_reason: task.stateReason,
usage: task.usage,
model: task.model,
thinking_effort: task.thinkingEffort,
};
}
private interactionOp(record: InteractionRecord): InteractionMessage {
return {
type: 'interaction',
...this.base(),
interaction_id: record.interactionId,
kind: record.kind,
status: record.status,
tool_call_id: record.toolCallId,
request: record.request,
response: record.response,
} as InteractionMessage;
}
private todoOp(): TodoMessage {
return {
type: 'todo',
...this.base(),
todo_id: TODO_ENTITY_ID,
items: this.todoItems ?? [],
updated_at: this.todoUpdatedAt,
};
}
private systemOp(
subtype: SystemMessage['subtype'],
payload: unknown,
time?: number,
): SystemMessage {
const systemId = this.sysIds.next(subtype);
this.timelineIds.push(systemId);
return {
type: 'system',
...this.base(),
system_id: systemId,
subtype,
payload,
at: time === undefined ? undefined : epochMsToIso(time),
} as SystemMessage;
}
}
export function toTurnOrigin(
origin: unknown,
agentId: string,
subagentTaskIds: ReadonlyMap<string, string>,
): TurnOrigin {
const candidate = origin as
| { kind?: unknown; taskId?: unknown; name?: unknown }
| null
| undefined;
const kind = typeof candidate?.kind === 'string' ? candidate.kind : undefined;
if (kind === undefined) return { kind: 'other' };
switch (kind) {
case 'user':
case 'skill_activation':
case 'plugin_command':
case 'shell_command':
return { kind: 'user' };
case 'cron_job':
case 'cron_missed':
return { kind: 'cron' };
case 'task':
case 'background_task': {
const taskId = candidate?.taskId;
return typeof taskId === 'string' ? { kind: 'task', task_id: taskId } : { kind: 'other' };
}
case 'hook_result':
return { kind: 'hook' };
case 'compaction_summary':
return { kind: 'compaction' };
case 'system_trigger': {
if (candidate?.name === 'goal_continuation') return { kind: 'goal' };
const taskId = subagentTaskIds.get(agentId);
return taskId === undefined ? { kind: 'other' } : { kind: 'task', task_id: taskId };
}
default:
return { kind: 'other' };
}
}
function nowIso(): string {
return new Date().toISOString();
}
function epochMsToIso(value: number): string {
return new Date(value).toISOString();
}
function restOf(event: {
readonly type: string;
readonly time?: number;
readonly agentId?: string;
}): Record<string, unknown> {
const { type: _type, time: _time, agentId: _agentId, ...rest } = event;
return rest;
}
function toSnakeUsage(usage: TokenUsage): StepUsage {
return {
input_other: usage.inputOther,
output: usage.output,
input_cache_read: usage.inputCacheRead,
input_cache_creation: usage.inputCacheCreation,
};
}
function turnUsageToWire(usage: StepUsage): {
input_tokens: number;
output_tokens: number;
cached_tokens: number;
} {
return {
input_tokens: usage.input_other + usage.input_cache_creation,
output_tokens: usage.output,
cached_tokens: usage.input_cache_read,
};
}
function timingOf(event: {
llmFirstTokenLatencyMs?: number;
llmStreamDurationMs?: number;
}): StepTiming | undefined {
if (event.llmFirstTokenLatencyMs === undefined && event.llmStreamDurationMs === undefined) {
return undefined;
}
return {
llm_first_token_ms: event.llmFirstTokenLatencyMs,
llm_stream_duration_ms: event.llmStreamDurationMs,
};
}
function mapTaskKind(kind: string): TaskMessage['kind'] {
switch (kind) {
case 'process':
return 'shell';
case 'agent':
return 'subagent';
default:
return 'other';
}
}
function agentInfoOf(info: AgentTaskInfo):
| {
agentId?: string;
parentToolCallId?: string;
model?: string;
thinkingEffort?: string;
}
| undefined {
if (info.kind !== 'agent') return undefined;
return info as {
agentId?: string;
parentToolCallId?: string;
model?: string;
thinkingEffort?: string;
};
}
function tailWindow(text: string): string {
return text.length <= TASK_OUTPUT_TAIL_MAX ? text : text.slice(text.length - TASK_OUTPUT_TAIL_MAX);
}
export function parseToolArgs(args: unknown): unknown {
if (typeof args !== 'string' || args.length === 0) return args;
try {
return JSON.parse(args) as unknown;
} catch {
return args;
}
}
export function todoWriteItems(input: unknown): readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[] | undefined {
const todos = (input as { todos?: unknown } | undefined)?.todos;
if (!Array.isArray(todos)) return undefined;
const items = readTodoItems(todos);
return items.length === 0 && todos.length > 0 ? undefined : items;
}
export function mapInteractionEndStatus(
kind: 'approval' | 'question',
response: unknown,
): InteractionMessage['status'] {
if (isCancellation(response)) return 'cancelled';
if (kind === 'question') return response === null ? 'dismissed' : 'answered';
const decision = (response as { decision?: unknown } | null | undefined)?.decision;
if (decision === 'approved' || decision === 'rejected' || decision === 'cancelled') {
return decision;
}
return 'cancelled';
}
export function isCancellation(response: unknown): boolean {
return (response as { cancelled?: unknown } | null | undefined)?.cancelled === true;
}
export function wantsUserMessage(origin: unknown, promptText: string | undefined): boolean {
const candidate = origin as { kind?: unknown; name?: unknown } | null | undefined;
switch (candidate?.kind) {
case 'user':
case 'skill_activation':
case 'plugin_command':
case 'shell_command':
case 'cron_job':
case 'cron_missed':
return true;
case 'system_trigger':
return (
candidate.name === 'subagent' &&
typeof promptText === 'string' &&
promptText.length > 0
);
default:
return false;
}
}
export function userOriginOf(origin: unknown): UserMessageOrigin | undefined {
const candidate = origin as
| {
kind?: unknown;
jobId?: unknown;
cron?: unknown;
skillName?: unknown;
skillArgs?: unknown;
trigger?: unknown;
pluginId?: unknown;
commandName?: unknown;
commandArgs?: unknown;
}
| null
| undefined;
if (candidate?.kind === 'cron_job') return cronUserOrigin(candidate);
if (candidate?.kind === 'cron_missed') return { kind: 'cron' };
if (candidate?.kind === 'skill_activation' && typeof candidate.skillName === 'string') {
return {
kind: 'skill',
skill_name: candidate.skillName,
args: typeof candidate.skillArgs === 'string' ? candidate.skillArgs : undefined,
trigger: typeof candidate.trigger === 'string' ? candidate.trigger : undefined,
};
}
if (candidate?.kind === 'plugin_command') {
const name =
typeof candidate.commandName === 'string'
? candidate.commandName
: typeof candidate.pluginId === 'string'
? candidate.pluginId
: undefined;
if (name === undefined) return undefined;
return {
kind: 'skill',
skill_name: name,
args: typeof candidate.commandArgs === 'string' ? candidate.commandArgs : undefined,
trigger: typeof candidate.trigger === 'string' ? candidate.trigger : undefined,
};
}
return undefined;
}
export function taskUserOriginOf(
taskId: unknown,
notification?: TaskNotificationPayload,
): Extract<UserMessageOrigin, { kind: 'task' }> | undefined {
if (typeof taskId !== 'string' || taskId.length === 0) return undefined;
if (notification === undefined) return { kind: 'task', task_id: taskId, title: '', body: '' };
return { kind: 'task', task_id: taskId, ...notification };
}
export function taskNotificationOriginOf(
origin: unknown,
): Extract<UserMessageOrigin, { kind: 'task' }> | undefined {
const candidate = origin as { kind?: unknown; taskId?: unknown } | null | undefined;
if (candidate?.kind !== 'task' && candidate?.kind !== 'background_task') return undefined;
return taskUserOriginOf(candidate.taskId);
}
export function notificationTextOf(notification: {
title: string;
body: string;
}): string {
return `${notification.title}\n${notification.body}`.trim();
}
function cronUserOrigin(candidate: {
jobId?: unknown;
cron?: unknown;
}): UserMessageOrigin | undefined {
if (typeof candidate.jobId !== 'string' || typeof candidate.cron !== 'string') return undefined;
return { kind: 'cron', cron_id: candidate.jobId, schedule: candidate.cron };
}
export function skillActivationsOf(
origin: unknown,
): { skill_name: string; skill_args?: string }[] | undefined {
const candidate = origin as {
kind?: unknown;
skillActivations?: readonly { skillName: string; skillArgs?: string }[];
skillName?: unknown;
skillArgs?: unknown;
} | null | undefined;
if (candidate?.kind === 'user') {
const activations = candidate.skillActivations ?? [];
if (activations.length === 0) return undefined;
return activations.map((a) => ({ skill_name: a.skillName, skill_args: a.skillArgs }));
}
if (candidate?.kind === 'skill_activation' && typeof candidate.skillName === 'string') {
return [
{
skill_name: candidate.skillName,
skill_args: typeof candidate.skillArgs === 'string' ? candidate.skillArgs : undefined,
},
];
}
return undefined;
}
export function promptTextOf(content: readonly ContentPart[]): string {
return content
.filter((part): part is ContentPart & { type: 'text' } => part.type === 'text')
.map((part) => part.text)
.join('');
}
export function steerKeyOf(
input: readonly ContentPart[],
skipBlocks: number,
): { text: string; attachments: number } {
let text = '';
let attachments = 0;
for (const part of input.slice(skipBlocks)) {
if (part.type === 'text') {
text += part.text;
continue;
}
if (daemonFileRefFromPart(part) !== undefined) attachments += 1;
}
return { text, attachments };
}
export function wireContentParts(content: readonly ContentPart[]): WireContentPart[] {
const out: WireContentPart[] = [];
for (const part of content) {
switch (part.type) {
case 'text':
out.push({ type: 'text', text: part.text, meta: {} });
break;
case 'think':
out.push({ type: 'think', text: part.think, meta: {} });
break;
case 'image_url':
out.push({
type: 'image',
text: part.imageUrl.url,
meta: { id: part.imageUrl.id, name: part.imageUrl.name },
});
break;
case 'audio_url':
out.push({ type: 'audio', text: part.audioUrl.url, meta: { id: part.audioUrl.id } });
break;
case 'video_url':
out.push({
type: 'video',
text: part.videoUrl.url,
meta: { id: part.videoUrl.id, name: part.videoUrl.name },
});
break;
default:
break;
}
}
return out;
}
export function textPartsOf(text: string): WireContentPart[] {
return [{ type: 'text', text, meta: {} }];
}
function hookPayload(event: {
turnId?: number;
hookEvent: string;
content: string;
blocked?: boolean;
}): Record<string, unknown> {
return {
turn_id: event.turnId,
hook_event: event.hookEvent,
content: event.content,
blocked: event.blocked,
};
}
export function wireInteractionRequest(kind: 'approval' | 'question', payload: unknown): unknown {
if (kind === 'approval') {
const record = payload as Record<string, unknown> | null;
const toolName = typeof record?.['toolName'] === 'string' ? record['toolName'] : undefined;
if (toolName === undefined || toolName.length === 0) return undefined;
return {
tool_name: toolName,
action: typeof record?.['action'] === 'string' ? record['action'] : '',
tool_input_display: record?.['display'],
};
}
return toV3QuestionRequest(payload);
}
export function wireInteractionResponse(
kind: 'approval' | 'question',
request: unknown,
response: unknown,
): unknown {
if (isCancellation(response)) {
return kind === 'approval' ? { decision: 'cancelled' } : undefined;
}
if (kind === 'approval') {
const r = response as {
decision?: unknown;
scope?: unknown;
feedback?: unknown;
selectedLabel?: unknown;
} | null;
if (r === null || typeof r !== 'object') return undefined;
const decision = r.decision;
if (decision !== 'approved' && decision !== 'rejected' && decision !== 'cancelled') {
return undefined;
}
return {
decision,
scope: r.scope === 'session' ? 'session' : undefined,
feedback: typeof r.feedback === 'string' ? r.feedback : undefined,
selected_label: typeof r.selectedLabel === 'string' ? r.selectedLabel : undefined,
};
}
return mapQuestionResponse(request, response);
}
export function toV3QuestionRequest(payload: unknown): unknown {
const request = payload as {
questions?: readonly {
question: string;
header?: string;
body?: string;
options: readonly { label: string; description?: string }[];
multiSelect?: boolean;
otherLabel?: string;
otherDescription?: string;
}[];
};
if (request.questions === undefined) return undefined;
return {
questions: request.questions.map((item, i) => ({
id: `q_${i}`,
question: item.question,
header: item.header,
body: item.body,
options: item.options.map((option, j) => ({
id: `opt_${i}_${j}`,
label: option.label,
description: option.description,
})),
multi_select: item.multiSelect,
allow_other: true,
other_label: item.otherLabel,
other_description: item.otherDescription,
})),
};
}
function mapQuestionResponse(request: unknown, response: unknown): unknown {
const r = response as { answers?: unknown; method?: unknown } | null;
if (r === null || typeof r !== 'object' || r.answers === null || typeof r.answers !== 'object') {
return undefined;
}
const items =
(
request as
| {
questions?: readonly {
id: string;
question: string;
options: readonly { id: string; label: string }[];
}[];
}
| undefined
)?.questions ?? [];
const answers: Record<string, unknown> = {};
for (const [key, value] of Object.entries(r.answers as Record<string, unknown>)) {
const item = items.find((q) => q.id === key || q.question === key);
if (item === undefined) continue;
if (value === true) {
answers[item.id] = { kind: 'skipped' };
continue;
}
if (typeof value !== 'string') continue;
const single = item.options.find((o) => o.label === value);
if (single !== undefined) {
answers[item.id] = { kind: 'single', option_id: single.id };
continue;
}
const parts = value.split(', ');
const optionIds = parts.flatMap((part) => {
const found = item.options.find((o) => o.label === part);
return found === undefined ? [] : [found.id];
});
if (parts.length > 1 && optionIds.length === parts.length) {
answers[item.id] = { kind: 'multi', option_ids: optionIds };
continue;
}
answers[item.id] = { kind: 'other', text: value };
}
if (Object.keys(answers).length === 0) return undefined;
const method = r.method;
return {
answers,
method:
method === 'enter' || method === 'space' || method === 'number_key' || method === 'click'
? method
: undefined,
};
}