graphlit-client
Version:
Graphlit API Client for TypeScript
960 lines (959 loc) • 39.4 kB
JavaScript
import { ConversationRoleTypes, ToolExecutionStatus as PersistedToolExecutionStatus, } from "../generated/graphql-types.js";
import { ChunkBuffer } from "./chunk-buffer.js";
/**
* Adapter that transforms low-level streaming events into high-level UI events
* using GraphQL types for type safety
*/
export class UIEventAdapter {
onEvent;
conversationId;
model; // This will now be the enum value
modelName; // This will be the actual model name (e.g., "claude-sonnet-4-0")
modelService;
tokenCount = 0;
currentMessage = "";
isStreaming = false;
conversationStartTime = 0; // When user sent the message
streamStartTime = 0; // When streaming actually began
firstTokenTime = 0;
lastTokenTime = 0;
tokenDelays = [];
activeToolCalls = new Map();
lastUpdateTime = 0;
updateTimer;
chunkBuffer;
smoothingDelay = 30;
chunkQueue = []; // Queue of chunks waiting to be emitted
contextWindowUsage;
finalMetrics;
reasoningContent = "";
reasoningFormat;
reasoningSignature;
isInReasoning = false;
reasoningBuffer;
reasoningEmitTimer;
lastReasoningEmitTime = 0;
hasEmittedFirstReasoning = false;
static REASONING_THROTTLE_MS = 250;
usageData;
hasToolCallsInProgress = false;
hadToolCallsBeforeResume = false;
getCachedInputTokens() {
return (this.usageData?.prompt_tokens_details?.cached_tokens ??
this.usageData?.input_tokens_details?.cached_tokens ??
this.usageData?.cached_tokens ??
this.usageData?.cachedTokens ??
this.usageData?.cached_content_tokens ??
this.usageData?.cachedContentTokenCount ??
this.usageData?.cache_read_input_tokens ??
this.usageData?.cacheReadInputTokens);
}
constructor(onEvent, conversationId, options = {}) {
this.onEvent = onEvent;
this.conversationId = conversationId;
this.smoothingDelay = options.smoothingDelay ?? 30;
this.model = options.model;
this.modelName = options.modelName;
this.modelService = options.modelService;
this.conversationStartTime = Date.now(); // Capture when conversation began
if (options.smoothingEnabled) {
this.chunkBuffer = new ChunkBuffer(options.chunkingStrategy || "word");
// Reasoning always uses sentence-level chunking regardless of the
// developer's choice — thinking content is secondary UI and benefits
// from fewer, larger updates rather than token-by-token emission.
this.reasoningBuffer = new ChunkBuffer("sentence");
}
}
setModelInfo(options) {
this.model = options.model;
this.modelName = options.modelName;
this.modelService = options.modelService;
}
/**
* Process a raw streaming event and emit appropriate UI events
*/
handleEvent(event) {
switch (event.type) {
case "start":
this.handleStart(event.conversationId);
break;
case "token":
this.handleToken(event.token);
break;
case "message":
this.handleMessage(event.message);
break;
case "tool_call_start":
this.handleToolCallStart(event.toolCall);
break;
case "tool_call_delta":
this.handleToolCallDelta(event.toolCallId, event.argumentDelta);
break;
case "tool_call_parsed":
this.handleToolCallParsed(event.toolCall);
break;
case "tool_call_executing":
this.handleToolCallExecuting(event.toolCall);
break;
case "tool_call_complete":
this.handleToolCallComplete(event.toolCall, event.result, event.error);
break;
case "complete":
this.handleComplete(event.tokens);
break;
case "error":
this.handleError(event.error);
break;
case "context_window":
this.handleContextWindow(event.usage);
break;
case "context_management":
this.handleContextManagement(event);
break;
case "reasoning_start":
this.handleReasoningStart(event.format);
break;
case "reasoning_delta":
this.handleReasoningDelta(event.content, event.format);
break;
case "reasoning_end":
this.handleReasoningEnd(event.fullContent, event.signature);
break;
}
}
handleStart(conversationId) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🚀 [UIEventAdapter] Handle start - Conversation ID: ${conversationId}`);
console.log(`🚀 [UIEventAdapter] Active tool calls at start: ${this.activeToolCalls.size}`);
}
this.conversationId = conversationId;
this.isStreaming = true;
this.streamStartTime = Date.now();
this.firstTokenTime = 0;
this.lastTokenTime = 0;
this.tokenCount = 0;
this.tokenDelays = [];
// Reset reasoning state so stale thinking from a prior round doesn't leak
this.reasoningContent = "";
this.reasoningFormat = undefined;
this.reasoningSignature = undefined;
this.isInReasoning = false;
this.lastReasoningEmitTime = 0;
this.hasEmittedFirstReasoning = false;
if (this.reasoningEmitTimer) {
globalThis.clearTimeout(this.reasoningEmitTimer);
this.reasoningEmitTimer = undefined;
}
if (this.reasoningBuffer) {
this.reasoningBuffer.flush();
}
// Reset tool call tracking flags
this.hasToolCallsInProgress = false;
this.hadToolCallsBeforeResume = false;
// Note: We only clear tool calls here if this is truly a new conversation start
// For multi-round tool calling, handleStart is only called once at the beginning
if (this.activeToolCalls.size > 0) {
console.log(`🚀 [UIEventAdapter] Warning: ${this.activeToolCalls.size} tool calls still active at start`);
}
this.activeToolCalls.clear();
this.emitUIEvent({
type: "conversation_started",
conversationId,
timestamp: new Date(),
model: this.model,
});
}
handleToken(token) {
// Track timing for first token
const now = Date.now();
if (this.firstTokenTime === 0) {
this.firstTokenTime = now;
}
// Track inter-token delays
if (this.lastTokenTime > 0) {
this.tokenDelays.push(now - this.lastTokenTime);
}
this.lastTokenTime = now;
this.tokenCount++;
// Check if we're resuming after tool calls and need to add newlines
if (this.hadToolCallsBeforeResume &&
this.hasToolCallsInProgress === false) {
// We had tool calls before and now we're receiving content again
// Add double newline to separate the content from tool results
if (this.currentMessage.length > 0 &&
!this.currentMessage.endsWith("\n\n")) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`📝 [UIEventAdapter] Adding newlines after tool calls before resuming content`);
}
this.currentMessage += "\n\n";
}
// Reset the flag now that we've added the newlines
this.hadToolCallsBeforeResume = false;
}
if (this.chunkBuffer) {
const chunks = this.chunkBuffer.addToken(token);
// Add chunks to queue for all chunking modes (character, word, sentence)
this.chunkQueue.push(...chunks);
this.scheduleChunkEmission();
}
else {
// No chunking - emit tokens directly
this.currentMessage += token;
this.scheduleMessageUpdate();
}
}
handleMessage(message) {
this.currentMessage = message;
this.emitMessageUpdate(false);
}
handleToolCallStart(toolCall) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool call start - ID: ${toolCall.id}, Name: ${toolCall.name}`);
console.log(`🔧 [UIEventAdapter] Active tool calls before: ${this.activeToolCalls.size}`);
}
// Flush chunk buffer and queue to currentMessage before tool calls begin.
// This ensures currentMessage reflects the full text so that the \n\n
// injection in handleToken works correctly when content resumes after
// tool calls complete. Without this, unflushed content (e.g. text ending
// with ':' that doesn't trigger the sentence boundary regex) stays in
// the buffer, causing currentMessage to be stale and the \n\n separator
// to either be skipped or placed at the wrong position.
if (this.chunkQueue.length > 0) {
this.currentMessage += this.chunkQueue.join("");
this.chunkQueue.length = 0;
}
if (this.chunkBuffer) {
const remaining = this.chunkBuffer.flush();
if (remaining.length > 0) {
this.currentMessage += remaining.join("");
}
}
// Clear any pending chunk emission timer since we just flushed everything
if (this.updateTimer) {
globalThis.clearTimeout(this.updateTimer);
this.updateTimer = undefined;
}
// Emit updated message so UI shows latest text before tool call indicators
if (this.currentMessage.length > 0) {
this.emitMessageUpdate(true);
}
const conversationToolCall = {
__typename: "ConversationToolCall",
id: toolCall.id,
name: toolCall.name,
arguments: "",
firstStatusAt: new Date().toISOString(),
};
this.activeToolCalls.set(toolCall.id, {
toolCall: conversationToolCall,
status: "preparing",
});
// Mark that we have tool calls in progress
this.hasToolCallsInProgress = true;
this.hadToolCallsBeforeResume = true;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Active tool calls after: ${this.activeToolCalls.size}`);
}
this.emitToolUpdate(conversationToolCall, "preparing");
}
handleToolCallDelta(toolCallId, argumentDelta) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool call delta - ID: ${toolCallId}, Delta length: ${argumentDelta.length}`);
console.log(`🔧 [UIEventAdapter] Delta content: ${argumentDelta.substring(0, 100)}...`);
}
const toolData = this.activeToolCalls.get(toolCallId);
if (toolData) {
toolData.toolCall.arguments += argumentDelta;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool ${toolCallId} accumulated args length: ${toolData.toolCall.arguments.length}`);
}
if (toolData.status === "preparing") {
toolData.status = "preparing";
}
this.emitToolUpdate(toolData.toolCall, toolData.status);
}
else {
console.warn(`🔧 [UIEventAdapter] WARNING: Tool call delta for unknown tool ID: ${toolCallId}`);
}
}
handleToolCallParsed(toolCall) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool call parsed - ID: ${toolCall.id}, Name: ${toolCall.name}`);
console.log(`🔧 [UIEventAdapter] Final arguments length: ${toolCall.arguments.length}`);
console.log(`🔧 [UIEventAdapter] Final arguments: ${toolCall.arguments.substring(0, 200)}...`);
}
const toolData = this.activeToolCalls.get(toolCall.id);
if (toolData) {
// Update the arguments with the final complete version
toolData.toolCall.arguments = toolCall.arguments;
// Mark as ready for execution, not completed
toolData.status = "ready";
this.emitToolUpdate(toolData.toolCall, "ready");
}
else {
// If we don't have this tool call tracked, create it now
console.warn(`🔧 [UIEventAdapter] Tool call parsed for untracked tool ID: ${toolCall.id}, creating entry`);
const conversationToolCall = {
__typename: "ConversationToolCall",
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
firstStatusAt: new Date().toISOString(),
};
this.activeToolCalls.set(toolCall.id, {
toolCall: conversationToolCall,
status: "ready",
});
// Mark that we have tool calls
this.hasToolCallsInProgress = true;
this.hadToolCallsBeforeResume = true;
this.emitToolUpdate(conversationToolCall, "ready");
}
}
handleToolCallExecuting(toolCall) {
const toolData = this.activeToolCalls.get(toolCall.id);
if (!toolData) {
return;
}
toolData.toolCall.arguments = toolCall.arguments;
toolData.toolCall.startedAt =
toolCall.startedAt ?? toolData.toolCall.startedAt ?? new Date().toISOString();
toolData.toolCall.firstStatusAt =
toolData.toolCall.firstStatusAt ??
toolCall.firstStatusAt ??
toolData.toolCall.startedAt;
toolData.status = "executing";
this.emitToolUpdate(toolData.toolCall, "executing");
}
handleToolCallComplete(toolCall, result, error) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool call complete - ID: ${toolCall.id}, Name: ${toolCall.name}`);
console.log(`🔧 [UIEventAdapter] Has result: ${!!result}, Has error: ${!!error}`);
}
const toolData = this.activeToolCalls.get(toolCall.id);
if (toolData) {
toolData.toolCall.arguments = toolCall.arguments;
toolData.toolCall.startedAt =
toolCall.startedAt ?? toolData.toolCall.startedAt;
toolData.toolCall.completedAt =
toolCall.completedAt ?? new Date().toISOString();
toolData.toolCall.firstStatusAt =
toolData.toolCall.firstStatusAt ??
toolCall.firstStatusAt ??
toolData.toolCall.startedAt ??
toolData.toolCall.completedAt;
toolData.toolCall.durationMs =
toolCall.durationMs ??
(toolData.toolCall.startedAt
? new Date(toolData.toolCall.completedAt).getTime() -
new Date(toolData.toolCall.startedAt).getTime()
: toolData.toolCall.durationMs);
toolData.toolCall.failedAt =
error
? (toolCall.failedAt ?? toolData.toolCall.completedAt)
: undefined;
toolData.toolCall.status = error
? PersistedToolExecutionStatus.Failed
: PersistedToolExecutionStatus.Completed;
toolData.status = error ? "failed" : "completed";
this.emitToolUpdate(toolData.toolCall, toolData.status, result, error);
}
else {
console.warn(`🔧 [UIEventAdapter] Tool call complete for unknown tool ID: ${toolCall.id}`);
}
// Check if all tool calls are complete
let allComplete = true;
for (const [, data] of this.activeToolCalls) {
if (data.status !== "completed" && data.status !== "failed") {
allComplete = false;
break;
}
}
if (allComplete && this.activeToolCalls.size > 0) {
// All tool calls are complete, mark that we're no longer processing tools
this.hasToolCallsInProgress = false;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] All tool calls complete, ready to resume content streaming`);
}
}
}
handleComplete(tokens) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔚 [UIEventAdapter] Handle complete - Active tool calls: ${this.activeToolCalls.size}`);
this.activeToolCalls.forEach((toolData, id) => {
console.log(`🔚 [UIEventAdapter] Tool ${id}: ${toolData.toolCall.name}, Status: ${toolData.status}, Args length: ${toolData.toolCall.arguments.length}`);
});
}
// Clear any pending updates
if (this.updateTimer) {
globalThis.clearTimeout(this.updateTimer);
this.updateTimer = undefined;
}
// Process any remaining chunks before completing
if (this.chunkQueue.length > 0) {
// Add all remaining chunks to current message
const remainingChunks = this.chunkQueue.join("");
const chunkCount = this.chunkQueue.length;
this.currentMessage += remainingChunks;
this.chunkQueue.length = 0; // Clear the queue after processing
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔚 [UIEventAdapter] Processed ${chunkCount} remaining chunks: "${remainingChunks}"`);
}
}
// Flush any remaining content from the buffer
if (this.chunkBuffer) {
const finalChunks = this.chunkBuffer.flush();
if (finalChunks.length > 0) {
const finalContent = finalChunks.join("");
this.currentMessage += finalContent;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔚 [UIEventAdapter] Flushed buffer with ${finalChunks.length} chunks: "${finalContent}"`);
}
}
}
// Emit a final message_update with the complete text so that consumers
// relying on message_update events (e.g. SSE forwarding) receive the
// fully-flushed content before the stream closes. Without this, the
// last message_update still contains the pre-flush truncated text.
if (this.currentMessage.length > 0) {
this.emitMessageUpdate(false);
}
this.isStreaming = false;
// Create final message with metadata
const finalMessage = {
__typename: "ConversationMessage",
role: ConversationRoleTypes.Assistant,
message: this.currentMessage,
timestamp: new Date().toISOString(),
tokens: tokens, // Now we have the actual LLM token count!
toolCalls: Array.from(this.activeToolCalls.values()).map((t) => t.toolCall),
model: this.model,
modelName: this.modelName,
modelService: this.modelService,
};
// Add final timing metadata
if (this.streamStartTime > 0) {
const totalTime = Date.now() - this.streamStartTime;
// Final throughput (chars/second) - includes entire duration
finalMessage.throughput =
totalTime > 0
? Math.round((this.currentMessage.length / totalTime) * 1000)
: 0;
// Total completion time in seconds
finalMessage.completionTime = totalTime / 1000;
// Add time to first token if we have it (useful metric)
if (this.firstTokenTime > 0) {
const ttft = this.firstTokenTime - this.streamStartTime;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`⏱️ [UIEventAdapter] TTFT: ${ttft}ms | Total: ${totalTime}ms | Throughput: ${finalMessage.throughput} chars/s`);
}
}
}
// Build final metrics
const completionTime = Date.now();
const finalMetrics = {
totalTime: this.streamStartTime > 0 ? completionTime - this.streamStartTime : 0,
conversationDuration: this.conversationStartTime > 0
? completionTime - this.conversationStartTime
: 0,
};
// Add TTFT if we have it
if (this.firstTokenTime > 0 && this.streamStartTime > 0) {
finalMetrics.ttft = this.firstTokenTime - this.streamStartTime;
}
// Add token counts
if (this.tokenCount > 0) {
finalMetrics.tokenCount = this.tokenCount; // Streaming chunks
}
if (tokens) {
finalMetrics.llmTokens = tokens; // Actual LLM tokens used
}
// Calculate average token delay
if (this.tokenDelays.length > 0) {
const avgDelay = this.tokenDelays.reduce((a, b) => a + b, 0) / this.tokenDelays.length;
finalMetrics.avgTokenDelay = Math.round(avgDelay);
}
// Calculate streaming throughput (excludes TTFT)
if (this.firstTokenTime > 0 && this.streamStartTime > 0) {
const streamingTime = completionTime - this.firstTokenTime;
if (streamingTime > 0) {
finalMetrics.streamingThroughput = Math.round((this.currentMessage.length / streamingTime) * 1000);
}
}
// Store final metrics for later retrieval
this.finalMetrics = finalMetrics;
// Check if there are tool calls that haven't been executed yet
const hasPendingToolCalls = Array.from(this.activeToolCalls.values()).some((toolData) => toolData.status === "ready" ||
toolData.status === "preparing" ||
toolData.status === "executing");
if (hasPendingToolCalls) {
// Don't emit conversation_completed yet - tool execution will continue
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔄 [UIEventAdapter] Skipping conversation_completed - ${this.activeToolCalls.size} tool calls pending execution`);
}
return; // Exit without emitting conversation_completed
}
// Attach reasoning metadata to the final message
const reasoning = this.buildReasoningMetadata();
if (reasoning) {
finalMessage.isThinking = true;
finalMessage.thinkingContent = reasoning.content;
}
// Include context window usage if available
const event = {
type: "conversation_completed",
message: finalMessage,
metrics: finalMetrics,
};
if (reasoning) {
event.reasoning = reasoning;
}
if (this.contextWindowUsage) {
event.contextWindow = this.contextWindowUsage;
}
// Add native provider usage data if available
if (this.usageData) {
const cachedInputTokens = this.getCachedInputTokens();
const cacheCreationInputTokens = this.usageData.cache_creation_input_tokens ??
this.usageData.cacheCreationInputTokens;
const cacheReadInputTokens = this.usageData.cache_read_input_tokens ??
this.usageData.cacheReadInputTokens;
event.usage = {
promptTokens: this.usageData.prompt_tokens ||
this.usageData.promptTokens ||
this.usageData.input_tokens ||
0,
completionTokens: this.usageData.completion_tokens ||
this.usageData.completionTokens ||
this.usageData.output_tokens ||
0,
totalTokens: this.usageData.total_tokens ||
this.usageData.totalTokens ||
(this.usageData.input_tokens || 0) +
(this.usageData.output_tokens || 0) ||
0,
model: this.model,
provider: this.modelService,
cachedInputTokens,
cacheCreationInputTokens,
cacheReadInputTokens,
metadata: this.usageData,
};
}
this.emitUIEvent(event);
}
handleError(error) {
this.emitError(error, false);
}
/**
* Emit a structured error event. Public so the client can pass through
* the `recoverable` flag from ProviderError after retries are exhausted.
*/
emitError(message, recoverable) {
this.isStreaming = false;
this.emitUIEvent({
type: "error",
error: {
message,
recoverable,
},
conversationId: this.conversationId,
timestamp: new Date(),
});
}
scheduleMessageUpdate() {
const now = Date.now();
const timeSinceLastUpdate = now - this.lastUpdateTime;
// If enough time has passed, update immediately
if (timeSinceLastUpdate >= this.smoothingDelay) {
this.emitMessageUpdate(true);
return;
}
// Otherwise, schedule an update
if (!this.updateTimer) {
const delay = this.smoothingDelay - timeSinceLastUpdate;
this.updateTimer = globalThis.setTimeout(() => {
this.emitMessageUpdate(true);
}, delay);
}
}
scheduleChunkEmission() {
// If timer is already running, let it handle the queue
if (this.updateTimer) {
return;
}
// If queue is empty, nothing to do
if (this.chunkQueue.length === 0) {
return;
}
const now = Date.now();
const timeSinceLastUpdate = now - this.lastUpdateTime;
// If enough time has passed, emit a chunk immediately
if (timeSinceLastUpdate >= this.smoothingDelay) {
this.emitNextChunk();
return;
}
// Otherwise, schedule the next chunk emission
const delay = this.smoothingDelay - timeSinceLastUpdate;
this.updateTimer = globalThis.setTimeout(() => {
this.emitNextChunk();
}, delay);
}
emitNextChunk() {
if (this.chunkQueue.length === 0) {
this.updateTimer = undefined;
return;
}
// Take one chunk from the queue
const chunk = this.chunkQueue.shift();
this.currentMessage += chunk;
// Emit the update
this.emitMessageUpdate(true);
// Schedule next chunk if queue is not empty
if (this.chunkQueue.length > 0) {
this.updateTimer = globalThis.setTimeout(() => {
this.emitNextChunk();
}, this.smoothingDelay);
}
else {
this.updateTimer = undefined;
}
}
emitMessageUpdate(isStreaming) {
this.lastUpdateTime = Date.now();
if (this.updateTimer) {
globalThis.clearTimeout(this.updateTimer);
this.updateTimer = undefined;
}
const message = {
__typename: "ConversationMessage",
role: ConversationRoleTypes.Assistant,
message: this.currentMessage,
timestamp: new Date().toISOString(),
};
// Add model metadata if available
if (this.model) {
message.model = this.model;
}
if (this.modelName) {
message.modelName = this.modelName;
}
if (this.modelService) {
message.modelService = this.modelService;
}
// Add timing metadata if streaming has started
if (this.streamStartTime > 0) {
const now = Date.now();
const elapsedTime = now - this.streamStartTime;
// Calculate throughput (chars/second)
const throughput = elapsedTime > 0
? Math.round((this.currentMessage.length / elapsedTime) * 1000)
: 0;
message.throughput = throughput;
// Add completion time if we have it (in seconds to match API)
if (elapsedTime > 0) {
message.completionTime = elapsedTime / 1000;
}
}
// Build metrics object
const now = Date.now();
const metrics = {
elapsedTime: this.streamStartTime > 0 ? now - this.streamStartTime : 0,
conversationDuration: this.conversationStartTime > 0 ? now - this.conversationStartTime : 0,
};
// Add TTFT if we have it
if (this.firstTokenTime > 0 && this.streamStartTime > 0) {
metrics.ttft = this.firstTokenTime - this.streamStartTime;
}
// Add token count if available
if (this.tokenCount > 0) {
metrics.tokenCount = this.tokenCount;
}
// Calculate average token delay
if (this.tokenDelays.length > 0) {
const avgDelay = this.tokenDelays.reduce((a, b) => a + b, 0) / this.tokenDelays.length;
metrics.avgTokenDelay = Math.round(avgDelay);
}
// Calculate streaming throughput (excludes TTFT)
if (this.firstTokenTime > 0 && this.streamStartTime > 0) {
const streamingTime = now - this.firstTokenTime;
if (streamingTime > 0) {
metrics.streamingThroughput = Math.round((this.currentMessage.length / streamingTime) * 1000);
}
}
// Attach reasoning metadata to the message and event
const reasoning = this.buildReasoningMetadata();
if (reasoning) {
message.isThinking = true;
message.thinkingContent = reasoning.content;
}
const event = {
type: "message_update",
message,
isStreaming,
metrics,
};
if (reasoning) {
event.reasoning = reasoning;
}
this.emitUIEvent(event);
}
emitUIEvent(event) {
this.onEvent(event);
}
emitToolUpdate(toolCall, status, result, error) {
const event = {
type: "tool_update",
toolCall,
status,
timestamp: new Date(),
result,
error,
};
if (toolCall.startedAt) {
event.startedAt = toolCall.startedAt;
}
if (toolCall.completedAt) {
event.completedAt = toolCall.completedAt;
}
if (toolCall.durationMs !== null && toolCall.durationMs !== undefined) {
event.durationMs = toolCall.durationMs;
}
if (toolCall.failedAt) {
event.failedAt = toolCall.failedAt;
}
this.emitUIEvent(event);
}
handleContextWindow(usage) {
// Store for later inclusion in completion event
this.contextWindowUsage = usage;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`📊 [UIEventAdapter] Context window: ${usage.usedTokens}/${usage.maxTokens} (${usage.percentage}%)`);
}
this.emitUIEvent({
type: "context_window",
usage,
timestamp: new Date(),
});
}
handleContextManagement(event) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`📊 [UIEventAdapter] Context management: ${event.action.type}`);
}
this.emitUIEvent({
type: "context_management",
action: event.action,
usage: event.usage,
timestamp: event.timestamp,
});
}
handleReasoningStart(format) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🤔 [UIEventAdapter] Reasoning start - Format: ${format}`);
}
this.isInReasoning = true;
this.reasoningFormat = format;
this.reasoningContent = "";
this.lastReasoningEmitTime = 0;
this.hasEmittedFirstReasoning = false;
// Reset the reasoning buffer for a fresh thinking block
if (this.reasoningBuffer) {
this.reasoningBuffer.flush();
}
if (this.reasoningEmitTimer) {
globalThis.clearTimeout(this.reasoningEmitTimer);
this.reasoningEmitTimer = undefined;
}
}
handleReasoningDelta(content, format) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🤔 [UIEventAdapter] Reasoning delta - Length: ${content.length}`);
}
this.reasoningContent += content;
this.reasoningFormat = format;
if (this.reasoningBuffer) {
// Emit the first delta immediately so the UI can show a "thinking"
// indicator without waiting for a sentence boundary.
if (!this.hasEmittedFirstReasoning) {
this.hasEmittedFirstReasoning = true;
this.reasoningBuffer.addToken(content);
this.emitReasoningUpdate();
return;
}
// Subsequent deltas: only emit when a complete sentence is available.
// No partial-sentence heartbeat — reasoning is secondary UI and
// handleReasoningEnd flushes the remainder.
const sentences = this.reasoningBuffer.addToken(content);
if (sentences.length > 0) {
this.scheduleReasoningEmission();
}
}
else {
// No smoothing — emit every delta immediately (original behavior)
this.emitUIEvent({
type: "reasoning_update",
content: this.reasoningContent,
format: format,
isComplete: false,
});
}
}
scheduleReasoningEmission() {
if (this.reasoningEmitTimer)
return;
const now = Date.now();
const elapsed = now - this.lastReasoningEmitTime;
if (elapsed >= UIEventAdapter.REASONING_THROTTLE_MS) {
this.emitReasoningUpdate();
}
else {
const delay = UIEventAdapter.REASONING_THROTTLE_MS - elapsed;
this.reasoningEmitTimer = globalThis.setTimeout(() => {
this.emitReasoningUpdate();
}, delay);
}
}
emitReasoningUpdate() {
this.reasoningEmitTimer = undefined;
this.lastReasoningEmitTime = Date.now();
if (this.reasoningFormat) {
this.emitUIEvent({
type: "reasoning_update",
content: this.reasoningContent,
format: this.reasoningFormat,
isComplete: false,
});
}
}
handleReasoningEnd(fullContent, signature) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🤔 [UIEventAdapter] Reasoning end - Final length: ${fullContent.length}, Has signature: ${!!signature}`);
if (signature) {
console.log(`🤔 [UIEventAdapter] Reasoning signature: ${signature}`);
}
}
// Cancel any pending throttled emission
if (this.reasoningEmitTimer) {
globalThis.clearTimeout(this.reasoningEmitTimer);
this.reasoningEmitTimer = undefined;
}
// Flush the reasoning buffer — any remaining partial sentence
if (this.reasoningBuffer) {
this.reasoningBuffer.flush();
}
this.isInReasoning = false;
this.reasoningContent = fullContent;
this.reasoningSignature = signature;
// Emit final reasoning update
if (this.reasoningFormat) {
this.emitUIEvent({
type: "reasoning_update",
content: fullContent,
format: this.reasoningFormat,
isComplete: true,
});
}
}
/**
* Build a ReasoningMetadata object from accumulated reasoning state.
* Returns undefined when no reasoning content has been captured.
*/
buildReasoningMetadata() {
if (!this.reasoningContent || !this.reasoningFormat) {
return undefined;
}
const metadata = {
content: this.reasoningContent,
format: this.reasoningFormat,
};
if (this.reasoningSignature) {
metadata.signature = this.reasoningSignature;
}
return metadata;
}
/**
* Clean up any pending timers
*/
/**
* Snapshot the current accumulated message so it can be restored on retry.
* Call this before each provider round begins.
*/
snapshotMessage() {
return this.currentMessage;
}
/**
* Reset streaming state to prepare for a provider retry.
* Restores the message to the given snapshot and clears partial buffers.
*/
resetForRetry(messageSnapshot) {
// Cancel pending timers
if (this.updateTimer) {
globalThis.clearTimeout(this.updateTimer);
this.updateTimer = undefined;
}
// Restore message to pre-round state
this.currentMessage = messageSnapshot;
// Clear chunk buffers
this.chunkQueue.length = 0;
if (this.chunkBuffer) {
this.chunkBuffer.flush();
}
// Reset per-round token tracking
this.firstTokenTime = 0;
this.lastTokenTime = 0;
// Reset reasoning state for the failed round
this.reasoningContent = "";
this.reasoningFormat = undefined;
this.reasoningSignature = undefined;
this.isInReasoning = false;
this.lastReasoningEmitTime = 0;
this.hasEmittedFirstReasoning = false;
if (this.reasoningEmitTimer) {
globalThis.clearTimeout(this.reasoningEmitTimer);
this.reasoningEmitTimer = undefined;
}
if (this.reasoningBuffer) {
this.reasoningBuffer.flush();
}
// Emit the restored message so the UI clears any partial content
this.emitMessageUpdate(false);
}
dispose() {
if (this.updateTimer) {
globalThis.clearTimeout(this.updateTimer);
this.updateTimer = undefined;
}
if (this.reasoningEmitTimer) {
globalThis.clearTimeout(this.reasoningEmitTimer);
this.reasoningEmitTimer = undefined;
}
this.activeToolCalls.clear();
}
/**
* Get the total completion time in milliseconds
*/
getCompletionTime() {
return this.finalMetrics?.totalTime;
}
/**
* Get the time to first token in milliseconds
*/
getTTFT() {
return this.finalMetrics?.ttft;
}
/**
* Get the throughput in tokens per second
*/
getThroughput() {
return this.finalMetrics?.streamingThroughput;
}
/**
* Set usage data from native provider
*/
setUsageData(usage) {
this.usageData = usage;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`📊 [UIEventAdapter] Usage data set:`, usage);
}
}
}