blob: 6f748024a78f84e26740b8d6dbf3feba7dae7c96 [file]
/**
* Materializer — converts durable messages into the view-model shape the UI
* renders. Lives in `runtime` (not `storage`) because correlation is a semantic
* operation, not a disk concern.
*
* Runtime/UI materializer for rebuilding chat and tool activity state from
* append-only stored messages.
*/
import type {
StoredMessage,
UserMessage,
AssistantMessage,
ToolCallMessage,
ToolResultMessage,
PermissionDecisionMessage,
TokenUsageMessage,
SystemNoteMessage,
ToolActivityKind,
ToolActivityStatus,
ToolResultContent,
TurnStatus,
} from '@maka/core';
import { deriveTurnRecords, projectToolActivityArgs } from '@maka/core';
import { toolResultActivityStatus, unfinishedToolActivityStatus } from '@maka/core';
// ============================================================================
// View-model types (mirror packages/ui/src exports, lifted here for reuse)
// ============================================================================
export interface ToolActivityItem {
toolUseId: string;
toolName: string;
activityKind?: ToolActivityKind;
displayName?: string;
intent?: string;
status: ToolActivityStatus;
args: unknown;
result?: ToolResultContent;
isError?: boolean;
durationMs?: number;
ts: number;
}
export type ChatItem =
| { kind: 'user'; message: UserMessage }
| { kind: 'assistant'; message: AssistantMessage }
| { kind: 'tool'; item: ToolActivityItem; decision?: PermissionDecisionMessage }
| { kind: 'system_note'; message: SystemNoteMessage };
export interface SessionViewModel {
items: ChatItem[];
totalTokens: { input: number; output: number; costUsd?: number };
}
// ============================================================================
// Materialize one shot (used on session reload)
// ============================================================================
/**
* Convert StoredMessage[] into a ChatItem[] for rendering.
*
* A ToolCallMessage with no matching ToolResultMessage is read against its
* turn: still `running` means the call is in flight, anything else makes it
* `interrupted`. Storage never synthesizes a fake ToolResultMessage — that's
* our job here.
*/
export function materializeSession(messages: readonly StoredMessage[]): SessionViewModel {
const items: ChatItem[] = [];
let totalInput = 0;
let totalOutput = 0;
let totalCostUsd = 0;
let hasCost = false;
// Index for tool correlation. ToolCallMessage.id === toolUseId, by §4.2.
const resultsByToolUseId = new Map<string, ToolResultMessage>();
const decisionsByToolUseId = new Map<string, PermissionDecisionMessage>();
const turnStatusById = new Map(
deriveTurnRecords(messages).map((turn) => [turn.turnId, turn.status]),
);
// First pass: index results + decisions.
for (const m of messages) {
if (m.type === 'tool_result') {
resultsByToolUseId.set(m.toolUseId, m);
} else if (m.type === 'permission_decision') {
decisionsByToolUseId.set(m.toolUseId, m);
}
}
// Second pass: emit ChatItems in document order, skipping tool_result + decision
// (they're folded into the tool ChatItem at the call site).
for (const m of messages) {
switch (m.type) {
case 'user':
items.push({ kind: 'user', message: m });
break;
case 'assistant':
items.push({ kind: 'assistant', message: m });
break;
case 'tool_call': {
const result = resultsByToolUseId.get(m.id);
const decision = decisionsByToolUseId.get(m.id);
items.push({
kind: 'tool',
item: toolActivityFromPair(m, result, turnStatusById.get(m.turnId)),
decision,
});
break;
}
case 'tool_result':
case 'permission_decision':
// folded into tool ChatItem above; skip here
break;
case 'token_usage':
totalInput += m.input;
totalOutput += m.output;
if (typeof m.costUsd === 'number') {
totalCostUsd += m.costUsd;
hasCost = true;
}
break;
case 'system_note':
items.push({ kind: 'system_note', message: m });
break;
}
}
return {
items,
totalTokens: {
input: totalInput,
output: totalOutput,
...(hasCost ? { costUsd: totalCostUsd } : {}),
},
};
}
/**
* Build a ToolActivityItem from a (ToolCallMessage, ToolResultMessage?) pair.
*
* - Missing result → `unfinishedToolActivityStatus` reads it against the turn
* - Cancelled shell / aborted explore → 'interrupted' (not failure)
* - Result with isError === true → 'errored' (includes permission deny/block)
* - Result with isError === false → 'completed'
*/
function toolActivityFromPair(
call: ToolCallMessage,
result: ToolResultMessage | undefined,
turnStatus: TurnStatus | undefined,
): ToolActivityItem {
if (result === undefined) {
return {
toolUseId: call.id,
toolName: call.toolName,
...(call.activityKind !== undefined ? { activityKind: call.activityKind } : {}),
...(call.displayName !== undefined ? { displayName: call.displayName } : {}),
...(call.intent !== undefined ? { intent: call.intent } : {}),
status: unfinishedToolActivityStatus(turnStatus),
args: projectToolActivityArgs(call.toolName, call.args),
ts: call.ts,
};
}
return {
toolUseId: call.id,
toolName: call.toolName,
...(call.activityKind !== undefined ? { activityKind: call.activityKind } : {}),
...(call.displayName !== undefined ? { displayName: call.displayName } : {}),
...(call.intent !== undefined ? { intent: call.intent } : {}),
status: toolResultActivityStatus(result.isError, result.content),
args: projectToolActivityArgs(call.toolName, call.args),
result: result.content,
isError: result.isError,
...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}),
ts: call.ts,
};
}
// ============================================================================
// Streaming patch for live updates (used during an active turn)
// ============================================================================
/**
* Apply a single newly-appended StoredMessage to an existing ChatItem[].
* Used by the renderer to incrementally update the view as events / writes
* land, without re-materializing the whole session.
*
* Returns the new items array (immutable update) and an optional patch hint
* indicating which existing item id was modified.
*/
export function applyAppendedMessage(
items: readonly ChatItem[],
message: StoredMessage,
): { items: ChatItem[]; modifiedToolUseId?: string } {
switch (message.type) {
case 'user':
return { items: [...items, { kind: 'user', message }] };
case 'assistant':
return { items: [...items, { kind: 'assistant', message }] };
case 'tool_call': {
const item: ToolActivityItem = {
toolUseId: message.id,
toolName: message.toolName,
...(message.activityKind !== undefined ? { activityKind: message.activityKind } : {}),
...(message.displayName !== undefined ? { displayName: message.displayName } : {}),
...(message.intent !== undefined ? { intent: message.intent } : {}),
status: 'pending',
args: projectToolActivityArgs(message.toolName, message.args),
ts: message.ts,
};
return { items: [...items, { kind: 'tool', item }] };
}
case 'tool_result': {
// Patch the matching tool ChatItem in place by toolUseId.
const next = items.map((it) => {
if (it.kind !== 'tool' || it.item.toolUseId !== message.toolUseId) return it;
return {
...it,
item: {
...it.item,
status: toolResultActivityStatus(message.isError, message.content),
result: message.content,
isError: message.isError,
...(message.durationMs !== undefined ? { durationMs: message.durationMs } : {}),
},
};
});
return { items: next, modifiedToolUseId: message.toolUseId };
}
case 'permission_decision': {
const next = items.map((it) => {
if (it.kind !== 'tool' || it.item.toolUseId !== message.toolUseId) return it;
return { ...it, decision: message };
});
return { items: next, modifiedToolUseId: message.toolUseId };
}
case 'system_note':
return { items: [...items, { kind: 'system_note', message }] };
case 'token_usage':
// No item to render; UI aggregates separately.
return { items: [...items] };
case 'turn_state':
// Turn metadata feeds the higher-level TurnViewModel projection; the
// incremental ChatItem stream has no standalone row for it.
return { items: [...items] };
}
}
// ============================================================================
// Event-driven UI status transitions (used during streaming)
// ============================================================================
/**
* Renderer helpers for transitioning ToolActivityItem.status based on
* SessionEvent stream (NOT JSONL replay). Idempotent by toolUseId per §10
* implementation notes — multiple events for the same id are merged.
*/
export function setToolStatus(
items: readonly ChatItem[],
toolUseId: string,
patch: Partial<Pick<ToolActivityItem, 'status' | 'result' | 'isError' | 'durationMs'>>,
): ChatItem[] {
return items.map((it) => {
if (it.kind !== 'tool' || it.item.toolUseId !== toolUseId) return it;
return { ...it, item: { ...it.item, ...patch } };
});
}