blob: 187f1dcdbf9a9c0b51a86e9f25e2b0ecaafc6fb6 [file]
/**
* RuntimeEvent adapters — narrow bridges between the durable StoredMessage
* model and the canonical RuntimeEvent fact model.
*
* Architecture: docs/architecture/runtime-core-architecture-draft.md
*
* Phase 1 scope: pure, synchronous, allocation-only adapters. They do NOT
* touch storage, do NOT mutate their inputs, and do NOT invent fields the
* source message cannot supply. Every adapter is total: when a message
* kind cannot be converted safely, the helper returns `null` (singular) or
* omits the entry (plural) rather than throwing.
*
* The reverse direction (RuntimeEvent → StoredMessage draft) is provided
* only for the straightforward user/model text cases. Tool, permission,
* token-usage, and lifecycle events are deliberately NOT forced back into
* StoredMessage shapes — those projections are owned by later nodes and
* the materializer already covers the UI path.
*
* The canonical event contract is imported from the `@maka/core` public API.
*/
import type { StoredMessage, UserMessage, AssistantMessage, SystemNoteMessage } from '@maka/core';
import { createRuntimeEventId, type RuntimeEvent } from '@maka/core';
// ============================================================================
// Shared context for legacy → event conversion
// ============================================================================
export interface StoredMessageEventContext {
sessionId: string;
invocationId: string;
runId: string;
/** Defaults to the message turnId (or '' for session-level notes). */
turnId?: string;
/** Defaults to the message ts. */
ts?: number;
/** id generator for new RuntimeEvents; defaults to createRuntimeEventId. */
newId?: () => string;
}
interface ResolvedEventCtx {
sessionId: string;
invocationId: string;
runId: string;
turnId: string;
ts: number;
newId: () => string;
}
function resolveCtx(ctx: StoredMessageEventContext, message: StoredMessage): ResolvedEventCtx {
return {
sessionId: ctx.sessionId,
invocationId: ctx.invocationId,
runId: ctx.runId,
turnId: ctx.turnId ?? message.turnId ?? '',
ts: ctx.ts ?? message.ts,
newId: ctx.newId ?? (() => createRuntimeEventId('rt-legacy')),
};
}
// ============================================================================
// StoredMessage → RuntimeEvent
// ============================================================================
/**
* Convert a legacy StoredMessage into the PRIMARY RuntimeEvent it carries.
*
* Safe conversions:
* user → role 'user', author 'user', text content
* assistant → role 'model', author 'agent', text content (thinking omitted)
* system_note → role 'system', author 'system', text content
*
* Returns null for tool_call, tool_result, permission_decision,
* token_usage, and turn_state — these need richer mapping (function_call /
* function_response content, actions, refs) that the runtime runner owns.
*
* Assistant `thinking` is dropped in this narrow singular form; use
* `storedMessageToRuntimeEvents` to capture thinking as a separate event.
*/
export function storedMessageToRuntimeEvent(
message: StoredMessage,
ctx: StoredMessageEventContext,
): RuntimeEvent | null {
const d = resolveCtx(ctx, message);
switch (message.type) {
case 'user':
return {
id: d.newId(),
invocationId: d.invocationId,
runId: d.runId,
sessionId: d.sessionId,
turnId: d.turnId,
ts: d.ts,
partial: false,
role: 'user',
author: message.origin ? 'host' : 'user',
content: {
kind: 'text',
text: message.text,
...(message.displayText !== undefined ? { displayText: message.displayText } : {}),
...(message.origin !== undefined ? { origin: message.origin } : {}),
...(message.attachments !== undefined && message.attachments.length > 0
? { attachments: message.attachments }
: {}),
...(message.quotes !== undefined && message.quotes.length > 0
? { quotes: message.quotes }
: {}),
...(message.inlineReferences !== undefined
? { inlineReferences: message.inlineReferences }
: {}),
},
refs: { storedMessageId: message.id },
};
case 'assistant':
return {
id: d.newId(),
invocationId: d.invocationId,
runId: d.runId,
sessionId: d.sessionId,
turnId: d.turnId,
ts: d.ts,
partial: false,
role: 'model',
author: 'agent',
content: {
kind: 'text',
text: message.text,
...(message.providerOptions !== undefined
? { providerOptions: structuredClone(message.providerOptions) }
: {}),
},
refs: { storedMessageId: message.id },
};
case 'system_note':
return {
id: d.newId(),
invocationId: d.invocationId,
runId: d.runId,
sessionId: d.sessionId,
turnId: d.turnId,
ts: d.ts,
partial: false,
role: 'system',
author: 'system',
content: { kind: 'text', text: systemNoteText(message) },
refs: { storedMessageId: message.id },
};
default:
return null;
}
}
/**
* Convert a legacy StoredMessage into ALL RuntimeEvents it implies.
*
* Multi-event cases:
* assistant with thinking → [model text event, model thinking event]
*
* Returns an empty array for message kinds that have no safe conversion
* (tool_call, tool_result, permission_decision, token_usage, turn_state).
*/
export function storedMessageToRuntimeEvents(
message: StoredMessage,
ctx: StoredMessageEventContext,
): RuntimeEvent[] {
const primary = storedMessageToRuntimeEvent(message, ctx);
const out: RuntimeEvent[] = [];
if (primary) out.push(primary);
if (message.type === 'assistant' && message.thinking) {
const d = resolveCtx(ctx, message);
const parts = message.thinking.parts ?? [message.thinking];
for (const part of parts) {
out.push({
id: d.newId(),
invocationId: d.invocationId,
runId: d.runId,
sessionId: d.sessionId,
turnId: d.turnId,
ts: d.ts,
partial: false,
role: 'model',
author: 'agent',
content: {
kind: 'thinking',
text: part.text,
...(part.signature !== undefined ? { signature: part.signature } : {}),
...(part.providerOptions !== undefined
? { providerOptions: structuredClone(part.providerOptions) }
: {}),
},
refs: { storedMessageId: message.id },
});
}
}
return out;
}
/**
* Stable, machine-readable label for a SystemNoteMessage. The model-history
* projection excludes system-role events by default, so this text never
* leaks into prompts; it exists only so the event carries a non-empty
* content payload for audit/UI projections.
*/
function systemNoteText(m: SystemNoteMessage): string {
return `system_note:${m.kind}`;
}
// ============================================================================
// RuntimeEvent → StoredMessage draft
// ============================================================================
export interface RuntimeEventToDraftOptions {
/**
* Required to emit AssistantMessage drafts from model-role text events.
* When omitted, model-role events return null (no safe legacy shape).
*/
modelId?: string;
/** id generator for new StoredMessages; defaults to createRuntimeEventId. */
newId?: () => string;
}
/**
* Convert a RuntimeEvent into a legacy StoredMessage draft, when the
* mapping is straightforward and lossless.
*
* Straightforward cases:
* role 'user' + text content → UserMessage
* role 'model' + text content → AssistantMessage (requires options.modelId)
*
* Returns null for every other shape (thinking, function_call,
* function_response, error, actions-only, system notes) — these are not
* forced into legacy storage. Tool/function projections will be owned by
* the runtime runner / tool-runtime nodes.
*
* The returned message is a complete StoredMessage with id/turnId/ts
* filled from the event (or generated when absent). The caller is
* responsible for appending it to the store.
*/
export function runtimeEventToStoredMessageDraft(
event: RuntimeEvent,
options: RuntimeEventToDraftOptions = {},
): StoredMessage | null {
if (event.partial) return null;
const newId = options.newId ?? (() => createRuntimeEventId('msg'));
const content = event.content;
if (!content) return null;
if (event.role === 'user' && content.kind === 'text') {
const draft: UserMessage = {
type: 'user',
id: event.refs?.storedMessageId ?? newId(),
turnId: event.turnId,
ts: event.ts,
text: content.text,
...(content.displayText !== undefined ? { displayText: content.displayText } : {}),
...(content.origin !== undefined ? { origin: content.origin } : {}),
...(content.attachments !== undefined && content.attachments.length > 0
? { attachments: content.attachments }
: {}),
...(content.quotes !== undefined && content.quotes.length > 0
? { quotes: content.quotes }
: {}),
...(content.inlineReferences !== undefined
? { inlineReferences: content.inlineReferences }
: {}),
};
return draft;
}
if (event.role === 'model' && content.kind === 'text') {
if (!options.modelId) return null;
const draft: AssistantMessage = {
type: 'assistant',
id: event.refs?.storedMessageId ?? newId(),
turnId: event.turnId,
ts: event.ts,
text: content.text,
...(content.providerOptions !== undefined
? { providerOptions: structuredClone(content.providerOptions) }
: {}),
modelId: options.modelId,
};
return draft;
}
return null;
}
// Re-export the id helper for adapter callers that want the same default.
export { createRuntimeEventId } from '@maka/core';