feat: include compaction messages and handle new summary message types (#756)

This commit is contained in:
jnjpng
2026-01-30 18:19:51 -08:00
committed by GitHub
parent bb6ce1f2c8
commit 37a237ad0f
10 changed files with 497 additions and 27 deletions

View File

@@ -5,7 +5,7 @@
"": {
"name": "@letta-ai/letta-code",
"dependencies": {
"@letta-ai/letta-client": "^1.7.6",
"@letta-ai/letta-client": "^1.7.7",
"glob": "^13.0.0",
"ink-link": "^5.0.0",
"open": "^10.2.0",
@@ -91,7 +91,7 @@
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.6", "", {}, "sha512-C/f03uE3TJdgfHk/8rRBxzWvY0YHCYAlrePHcTd0CRHMo++0TA1OTcgiCF+EFVDVYGzfPSeMpqgAZTNvD9r9GQ=="],
"@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.7", "", {}, "sha512-jPq/hpctvN7DGnOg+1yRWAMdPhWeVxh89qOhQMzhO55q6/ai/yXHJ0hoXiPbQOZWHb2kO+WwjsLROVkb5Hj8ow=="],
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],

View File

@@ -30,7 +30,7 @@
"access": "public"
},
"dependencies": {
"@letta-ai/letta-client": "^1.7.6",
"@letta-ai/letta-client": "^1.7.7",
"glob": "^13.0.0",
"ink-link": "^5.0.0",
"open": "^10.2.0",

View File

@@ -159,7 +159,10 @@ export async function getResumeData(
try {
const backfill = await client.conversations.messages.list(
conversationId,
{ limit: MESSAGE_HISTORY_LIMIT, order: "desc" },
{
limit: MESSAGE_HISTORY_LIMIT,
order: "desc",
},
);
return {
pendingApproval: null,

View File

@@ -72,6 +72,7 @@ export async function sendMessageStream(
stream_tokens: opts.streamTokens ?? true,
background: opts.background ?? true,
client_tools: getClientToolsFromRegistry(),
include_compaction_messages: true,
},
requestOptions,
);
@@ -81,10 +82,11 @@ export async function sendMessageStream(
conversationId,
{
messages: messages,
stream: true,
streaming: true,
stream_tokens: opts.streamTokens ?? true,
background: opts.background ?? true,
client_tools: getClientToolsFromRegistry(),
include_compaction_messages: true,
},
requestOptions,
);

View File

@@ -117,6 +117,7 @@ import { ConversationSelector } from "./components/ConversationSelector";
import { colors } from "./components/colors";
// EnterPlanModeDialog removed - now using InlineEnterPlanModeApproval
import { ErrorMessage } from "./components/ErrorMessageRich";
import { EventMessage } from "./components/EventMessage";
import { FeedbackDialog } from "./components/FeedbackDialog";
import { HelpDialog } from "./components/HelpDialog";
import { HooksManager } from "./components/HooksManager";
@@ -1473,6 +1474,12 @@ export default function App({
newlyCommitted.push({ ...ln });
continue;
}
// Events only commit when finished (they have running/finished phases)
if (ln.kind === "event" && ln.phase === "finished") {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
continue;
}
// Commands with phase should only commit when finished
if (ln.kind === "command" || ln.kind === "bash_command") {
if (!ln.phase || ln.phase === "finished") {
@@ -9394,6 +9401,10 @@ Plan file path: ${planFilePath}`;
// Always show other tool calls in progress
return ln.phase !== "finished";
}
// Events (like compaction) show while running
if (ln.kind === "event") {
return ln.phase === "running";
}
if (!tokenStreamingEnabled && ln.phase === "streaming") return false;
return ln.phase === "streaming";
});
@@ -9577,6 +9588,8 @@ Plan file path: ${planFilePath}`;
<ErrorMessage line={item} />
) : item.kind === "status" ? (
<StatusMessage line={item} />
) : item.kind === "event" ? (
<EventMessage line={item} />
) : item.kind === "separator" ? (
<Box marginTop={1}>
<Text dimColor>{"─".repeat(columns)}</Text>
@@ -9722,6 +9735,8 @@ Plan file path: ${planFilePath}`;
<ErrorMessage line={ln} />
) : ln.kind === "status" ? (
<StatusMessage line={ln} />
) : ln.kind === "event" ? (
<EventMessage line={ln} />
) : ln.kind === "command" ? (
<CommandMessage line={ln} />
) : ln.kind === "bash_command" ? (

View File

@@ -0,0 +1,174 @@
import { Text } from "ink";
import { memo, useEffect, useState } from "react";
// Default configuration
const DEFAULT_GARBAGE_CHARS = "._";
const DEFAULT_TICK_MS = 30;
const DEFAULT_MIN_GARBAGE = 1;
const DEFAULT_MAX_GARBAGE = 2;
const DEFAULT_CURSOR = "█";
// Generate random garbage string
function generateGarbage(count: number, chars: string): string {
let result = "";
for (let i = 0; i < count; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
export interface FanOutAnimationOptions {
/** Characters to use for garbage/noise before revealing real chars */
garbageChars?: string;
/** Milliseconds between animation frames */
tickMs?: number;
/** Minimum garbage characters before each reveal (default 1) */
minGarbage?: number;
/** Maximum garbage characters before each reveal (default 2) */
maxGarbage?: number;
/** Cursor character shown at the end (default █) */
cursor?: string;
/** Whether to show cursor after animation completes */
showCursorOnComplete?: boolean;
}
export interface FanOutAnimationProps extends FanOutAnimationOptions {
/** The text to animate */
text: string;
/** Called when animation completes */
onComplete?: () => void;
/** Text styling */
bold?: boolean;
dimColor?: boolean;
}
/**
* Pre-generate all animation frames at initialization.
* Follows 3-state cycle:
* 1. Cursor flush against revealed text (no garbage)
* 2. Garbage characters appear
* 3. Garbage replaced with same number of real characters
*/
function generateFrames(
text: string,
garbageChars: string,
minGarbage: number,
maxGarbage: number,
cursor: string,
): string[] {
const frames: string[] = [];
let position = 0;
// State 1: Initial frame - just cursor
frames.push(cursor);
while (position < text.length) {
const remaining = text.length - position;
const range = maxGarbage - minGarbage + 1;
const count = Math.min(
Math.floor(Math.random() * range) + minGarbage,
remaining,
);
// State 2: Garbage appears
const revealed = text.slice(0, position);
const garbage = generateGarbage(count, garbageChars);
frames.push(`${revealed}${garbage}${cursor}`);
// State 3: Garbage replaced with real chars (same count)
position += count;
const newRevealed = text.slice(0, position);
frames.push(`${newRevealed}${cursor}`);
}
// Final frame: complete text without cursor
frames.push(text);
return frames;
}
/**
* Hook for fan-out animation logic.
* Pre-computes all frames, then cycles through with a simple index.
*/
export function useFanOutAnimation(
text: string,
options: FanOutAnimationOptions = {},
onComplete?: () => void,
): { display: string; isComplete: boolean } {
const {
garbageChars = DEFAULT_GARBAGE_CHARS,
tickMs = DEFAULT_TICK_MS,
minGarbage = DEFAULT_MIN_GARBAGE,
maxGarbage = DEFAULT_MAX_GARBAGE,
cursor = DEFAULT_CURSOR,
showCursorOnComplete = false,
} = options;
// Pre-generate frames once on mount
const [frames] = useState(() =>
generateFrames(text, garbageChars, minGarbage, maxGarbage, cursor),
);
// Simple index state - just increment each tick
const [frameIndex, setFrameIndex] = useState(0);
const isComplete = frameIndex >= frames.length - 1;
useEffect(() => {
if (isComplete) {
onComplete?.();
return;
}
const timer = setInterval(() => {
setFrameIndex((prev) => Math.min(prev + 1, frames.length - 1));
}, tickMs);
return () => clearInterval(timer);
}, [isComplete, frames.length, tickMs, onComplete]);
const display = frames[frameIndex] ?? text;
const finalDisplay =
isComplete && !showCursorOnComplete
? text
: isComplete && showCursorOnComplete
? `${text}${cursor}`
: display;
return { display: finalDisplay, isComplete };
}
/**
* Generic fan-out animation component.
* Characters reveal left-to-right with random garbage chars before each reveal.
*/
export const FanOutAnimation = memo(
({
text,
onComplete,
bold = false,
dimColor = false,
...options
}: FanOutAnimationProps) => {
const { display } = useFanOutAnimation(text, options, onComplete);
return (
<Text bold={bold} dimColor={dimColor} wrap="truncate">
{display}
</Text>
);
},
);
FanOutAnimation.displayName = "FanOutAnimation";
/**
* Animated "Compacting..." text with cursor block effect.
* Convenience wrapper around FanOutAnimation.
*/
export const CompactingAnimation = memo(() => {
return <FanOutAnimation text="Compacting..." bold />;
});
CompactingAnimation.displayName = "CompactingAnimation";

View File

@@ -0,0 +1,122 @@
import { Box, Text } from "ink";
import { memo } from "react";
import { COMPACTION_SUMMARY_HEADER } from "../../constants";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { BlinkDot } from "./BlinkDot.js";
import { CompactingAnimation } from "./CompactingAnimation";
import { colors } from "./colors.js";
type EventLine = {
kind: "event";
id: string;
eventType: string;
eventData: Record<string, unknown>;
phase: "running" | "finished";
summary?: string;
stats?: {
trigger?: string;
contextTokensBefore?: number;
contextTokensAfter?: number;
contextWindow?: number;
messagesCountBefore?: number;
messagesCountAfter?: number;
};
};
/**
* EventMessage - Displays compaction events like a tool call
*
* When running: Shows blinking dot with "Compacting..."
* When finished: Shows completed dot with summary
*/
export const EventMessage = memo(({ line }: { line: EventLine }) => {
const columns = useTerminalWidth();
const rightWidth = Math.max(0, columns - 2);
// Only handle compaction events for now
if (line.eventType !== "compaction") {
return (
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text dimColor></Text>
</Box>
<Box flexGrow={1} width={rightWidth}>
<Text dimColor>Event: {line.eventType}</Text>
</Box>
</Box>
);
}
const isRunning = line.phase === "running";
// Dot indicator based on phase
const dotElement = isRunning ? (
<BlinkDot color={colors.tool.running} />
) : (
<Text color={colors.tool.completed}></Text>
);
// Format the args display (message count or fallback)
const formatArgs = (): string => {
const stats = line.stats;
if (
stats?.messagesCountBefore !== undefined &&
stats?.messagesCountAfter !== undefined
) {
return `${stats.messagesCountBefore}${stats.messagesCountAfter} messages`;
}
return "...";
};
const argsDisplay = formatArgs();
return (
<Box flexDirection="column">
{/* Main tool call line */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
{dotElement}
</Box>
<Box flexGrow={1} width={rightWidth}>
{isRunning ? (
<CompactingAnimation />
) : (
<Text bold>Compact({argsDisplay})</Text>
)}
</Box>
</Box>
{/* Result section (only when finished) - matches CollapsedOutputDisplay format */}
{!isRunning && line.summary && (
<>
{/* Header line with L-bracket */}
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text dimColor>{" ⎿ "}</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, rightWidth - 3)}>
<Text dimColor>{COMPACTION_SUMMARY_HEADER}</Text>
</Box>
</Box>
{/* Empty line for separation */}
<Box flexDirection="row">
<Text> </Text>
</Box>
{/* Summary text - indented with 5 spaces to align */}
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, rightWidth - 3)}>
<Text dimColor wrap="wrap">
{line.summary}
</Text>
</Box>
</Box>
</>
)}
</Box>
);
});
EventMessage.displayName = "EventMessage";

View File

@@ -5,10 +5,7 @@
// - Exposes `onChunk` to feed SDK events and `toLines` to render.
import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages";
import {
COMPACTION_SUMMARY_HEADER,
INTERRUPTED_BY_USER,
} from "../../constants";
import { INTERRUPTED_BY_USER } from "../../constants";
import { runPostToolUseHooks, runPreToolUseHooks } from "../../hooks";
import { extractCompactionSummary } from "./backfill";
import { findLastSafeSplitPoint } from "./markdownSplit";
@@ -136,6 +133,23 @@ export type Line =
streaming?: StreamingState;
}
| { kind: "error"; id: string; text: string }
| {
kind: "event";
id: string;
eventType: string;
eventData: Record<string, unknown>;
// Compaction events have additional fields populated when summary_message arrives
phase: "running" | "finished";
summary?: string;
stats?: {
trigger?: string;
contextTokensBefore?: number;
contextTokensAfter?: number;
contextWindow?: number;
messagesCountBefore?: number;
messagesCountAfter?: number;
};
}
| {
kind: "command";
id: string;
@@ -522,14 +536,17 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
const rawText = extractTextPart(chunk.content);
if (!rawText) break;
// Check if this is a compaction summary message
// Check if this is a compaction summary message (old format embedded in user_message)
const compactionSummary = extractCompactionSummary(rawText);
if (compactionSummary) {
// Render as a user message with context header and summary
// Render as a finished compaction event
ensure(b, id, () => ({
kind: "user",
kind: "event",
id,
text: `${COMPACTION_SUMMARY_HEADER}\n\n${compactionSummary}`,
eventType: "compaction",
eventData: {},
phase: "finished",
summary: compactionSummary,
}));
}
// If not a summary, ignore it (user messages aren't rendered during streaming)
@@ -804,8 +821,80 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
break;
}
default:
break; // ignore ping/etc
default: {
// Handle new compaction message types (when include_compaction_messages=true)
// These are not yet in the SDK types, so we handle them via string comparison
const msgType = chunk.message_type as string | undefined;
if (msgType === "summary_message") {
// Use otid if available, fall back to id
const summaryChunk = chunk as LettaStreamingResponse & {
id?: string;
otid?: string;
summary?: string;
compaction_stats?: {
trigger?: string;
context_tokens_before?: number;
context_tokens_after?: number;
context_window?: number;
messages_count_before?: number;
messages_count_after?: number;
};
};
const summaryText = summaryChunk.summary || "";
const stats = summaryChunk.compaction_stats;
// Find the most recent compaction event line and update it with summary and stats
for (let i = b.order.length - 1; i >= 0; i--) {
const orderId = b.order[i];
if (!orderId) continue;
const line = b.byId.get(orderId);
if (line?.kind === "event" && line.eventType === "compaction") {
line.phase = "finished";
line.summary = summaryText;
if (stats) {
line.stats = {
trigger: stats.trigger,
contextTokensBefore: stats.context_tokens_before,
contextTokensAfter: stats.context_tokens_after,
contextWindow: stats.context_window,
messagesCountBefore: stats.messages_count_before,
messagesCountAfter: stats.messages_count_after,
};
}
break;
}
}
break;
}
if (msgType === "event_message") {
// Use otid if available, fall back to id
const eventChunk = chunk as LettaStreamingResponse & {
id?: string;
otid?: string;
event_type?: string;
event_data?: Record<string, unknown>;
};
const id = eventChunk.otid || eventChunk.id;
if (!id) break;
// Handle otid transition (mark previous line as finished)
handleOtidTransition(b, id);
ensure(b, id, () => ({
kind: "event",
id,
eventType: eventChunk.event_type || "unknown",
eventData: eventChunk.event_data || {},
phase: "running",
}));
break;
}
// ignore ping/etc
break;
}
}
}

View File

@@ -5,11 +5,7 @@ import type {
Message,
TextContent,
} from "@letta-ai/letta-client/resources/agents/messages";
import {
COMPACTION_SUMMARY_HEADER,
SYSTEM_REMINDER_CLOSE,
SYSTEM_REMINDER_OPEN,
} from "../../constants";
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants";
import type { Buffers } from "./accumulator";
/**
@@ -183,15 +179,18 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void {
case "user_message": {
const rawText = renderUserContentParts(msg.content);
// Check if this is a compaction summary message (system_alert with summary)
// Check if this is a compaction summary message (old format embedded in user_message)
const compactionSummary = extractCompactionSummary(rawText);
if (compactionSummary) {
// Render as a user message with context header and summary
// Render as a finished compaction event
const exists = buffers.byId.has(lineId);
buffers.byId.set(lineId, {
kind: "user",
kind: "event",
id: lineId,
text: `${COMPACTION_SUMMARY_HEADER}\n\n${compactionSummary}`,
eventType: "compaction",
eventData: {},
phase: "finished",
summary: compactionSummary,
});
if (!exists) buffers.order.push(lineId);
break;
@@ -336,8 +335,74 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void {
break;
}
default:
break; // ignore other message types
default: {
// Handle new compaction message types (when include_compaction_messages=true)
// These are not yet in the SDK types, so we handle them via string comparison
const msgType = msg.message_type as string | undefined;
if (msgType === "summary_message") {
// SummaryMessage has: summary (str), compaction_stats (optional)
const summaryMsg = msg as Message & {
summary?: string;
compaction_stats?: {
trigger?: string;
context_tokens_before?: number;
context_tokens_after?: number;
context_window?: number;
messages_count_before?: number;
messages_count_after?: number;
};
};
const summaryText = summaryMsg.summary || "";
const stats = summaryMsg.compaction_stats;
// Find the most recent compaction event line and update it with summary and stats
for (let i = buffers.order.length - 1; i >= 0; i--) {
const orderId = buffers.order[i];
if (!orderId) continue;
const line = buffers.byId.get(orderId);
if (line?.kind === "event" && line.eventType === "compaction") {
line.phase = "finished";
line.summary = summaryText;
if (stats) {
line.stats = {
trigger: stats.trigger,
contextTokensBefore: stats.context_tokens_before,
contextTokensAfter: stats.context_tokens_after,
contextWindow: stats.context_window,
messagesCountBefore: stats.messages_count_before,
messagesCountAfter: stats.messages_count_after,
};
}
break;
}
}
break;
}
if (msgType === "event_message") {
// EventMessage has: event_type (str), event_data (dict)
const eventMsg = msg as Message & {
event_type?: string;
event_data?: Record<string, unknown>;
};
const exists = buffers.byId.has(lineId);
buffers.byId.set(lineId, {
kind: "event",
id: lineId,
eventType: eventMsg.event_type || "unknown",
eventData: eventMsg.event_data || {},
phase: "finished", // In backfill, events are always finished (summary already processed)
});
if (!exists) buffers.order.push(lineId);
break;
}
// ignore other message types
break;
}
}
}

View File

@@ -34,7 +34,7 @@ export const MEMFS_CONFLICT_CHECK_INTERVAL = 5;
* Header displayed before compaction summary when conversation context is truncated
*/
export const COMPACTION_SUMMARY_HEADER =
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.";
"(Earlier messages in this conversation have been compacted to free up context, summarized below)";
/**
* Status bar thresholds - only show indicators when values exceed these