feat(harness): add system alert for conversation switching (#910)

This commit is contained in:
Kian Jones
2026-02-12 12:43:23 -08:00
committed by GitHub
parent cbd46b9923
commit a81b8f7b5d
5 changed files with 283 additions and 6 deletions

View File

@@ -865,6 +865,11 @@ export default function App({
const resumeKey = useSuspend();
// Pending conversation switch context — consumed on first message after a switch
const pendingConversationSwitchRef = useRef<
import("./helpers/conversationSwitchAlert").ConversationSwitchContext | null
>(null);
// Track previous prop values to detect actual prop changes (not internal state changes)
const prevInitialAgentIdRef = useRef(initialAgentId);
const prevInitialAgentStateRef = useRef(initialAgentState);
@@ -4997,6 +5002,31 @@ export default function App({
setLlmConfig(agent.llm_config);
setConversationId(targetConversationId);
// Set conversation switch context for agent switch
{
const { getModelDisplayName } = await import("../agent/model");
const modelHandle =
agent.model ||
(agent.llm_config?.model_endpoint_type && agent.llm_config?.model
? `${agent.llm_config.model_endpoint_type}/${agent.llm_config.model}`
: null);
const modelLabel =
(modelHandle && getModelDisplayName(modelHandle)) ||
modelHandle ||
"unknown";
pendingConversationSwitchRef.current = {
origin: "agent-switch",
conversationId: targetConversationId,
isDefault: targetConversationId === "default",
agentSwitchContext: {
name: agent.name || targetAgentId,
description: agent.description ?? undefined,
model: modelLabel,
blockCount: agent.blocks?.length ?? 0,
},
};
}
// Reset context token tracking for new agent
resetContextHistory(contextTrackerRef.current);
@@ -6379,6 +6409,12 @@ export default function App({
// Update conversationId state
setConversationId(conversation.id);
pendingConversationSwitchRef.current = {
origin: "new",
conversationId: conversation.id,
isDefault: false,
};
// Save the new session to settings
settingsManager.setLocalLastSession(
{ agentId, conversationId: conversation.id },
@@ -6451,6 +6487,13 @@ export default function App({
isolated_block_labels: [...ISOLATED_BLOCK_LABELS],
});
setConversationId(conversation.id);
pendingConversationSwitchRef.current = {
origin: "clear",
conversationId: conversation.id,
isDefault: false,
};
settingsManager.setLocalLastSession(
{ agentId, conversationId: conversation.id },
process.cwd(),
@@ -6777,6 +6820,15 @@ export default function App({
// Only update state after validation succeeds
setConversationId(targetConvId);
pendingConversationSwitchRef.current = {
origin: "resume-direct",
conversationId: targetConvId,
isDefault: targetConvId === "default",
messageCount: resumeData.messageHistory.length,
messageHistory: resumeData.messageHistory,
};
settingsManager.setLocalLastSession(
{ agentId, conversationId: targetConvId },
process.cwd(),
@@ -8047,8 +8099,24 @@ ${SYSTEM_REMINDER_CLOSE}
}
}
// Build conversation switch alert if a switch is pending (behind feature flag)
let conversationSwitchAlert = "";
if (
pendingConversationSwitchRef.current &&
settingsManager.getSetting("conversationSwitchAlertEnabled")
) {
const { buildConversationSwitchAlert } = await import(
"./helpers/conversationSwitchAlert"
);
conversationSwitchAlert = buildConversationSwitchAlert(
pendingConversationSwitchRef.current,
);
}
pendingConversationSwitchRef.current = null;
pushReminder(sessionStartHookFeedback);
pushReminder(permissionModeAlert);
pushReminder(conversationSwitchAlert);
pushReminder(planModeReminder);
pushReminder(ralphModeReminder);
@@ -9793,6 +9861,15 @@ ${SYSTEM_REMINDER_CLOSE}
);
setConversationId(action.conversationId);
pendingConversationSwitchRef.current = {
origin: "resume-selector",
conversationId: action.conversationId,
isDefault: action.conversationId === "default",
messageCount: resumeData.messageHistory.length,
messageHistory: resumeData.messageHistory,
};
settingsManager.setLocalLastSession(
{ agentId, conversationId: action.conversationId },
process.cwd(),
@@ -11009,7 +11086,7 @@ Plan file path: ${planFilePath}`;
agentId={agentId}
agentName={agentName ?? undefined}
currentConversationId={conversationId}
onSelect={async (convId) => {
onSelect={async (convId, selectorContext) => {
const overlayCommand = consumeOverlayCommand("conversations");
closeOverlay();
@@ -11071,6 +11148,18 @@ Plan file path: ${planFilePath}`;
// Only update state after validation succeeds
setConversationId(convId);
pendingConversationSwitchRef.current = {
origin: "resume-selector",
conversationId: convId,
isDefault: convId === "default",
messageCount:
selectorContext?.messageCount ??
resumeData.messageHistory.length,
summary: selectorContext?.summary,
messageHistory: resumeData.messageHistory,
};
settingsManager.setLocalLastSession(
{ agentId, conversationId: convId },
process.cwd(),
@@ -11289,7 +11378,11 @@ Plan file path: ${planFilePath}`;
initialQuery={searchQuery || undefined}
agentId={agentId}
conversationId={conversationId}
onOpenConversation={async (targetAgentId, targetConvId) => {
onOpenConversation={async (
targetAgentId,
targetConvId,
searchContext,
) => {
const overlayCommand = consumeOverlayCommand("search");
closeOverlay();
@@ -11358,6 +11451,17 @@ Plan file path: ${planFilePath}`;
);
setConversationId(actualTargetConv);
pendingConversationSwitchRef.current = {
origin: "search",
conversationId: actualTargetConv,
isDefault: actualTargetConv === "default",
messageCount: resumeData.messageHistory.length,
messageHistory: resumeData.messageHistory,
searchQuery: searchContext?.query,
searchMessage: searchContext?.message,
};
settingsManager.setLocalLastSession(
{ agentId, conversationId: actualTargetConv },
process.cwd(),

View File

@@ -17,7 +17,13 @@ interface ConversationSelectorProps {
agentId: string;
agentName?: string;
currentConversationId: string;
onSelect: (conversationId: string) => void;
onSelect: (
conversationId: string,
context?: {
summary?: string;
messageCount: number;
},
) => void;
onNewConversation: () => void;
onCancel: () => void;
}
@@ -370,7 +376,10 @@ export function ConversationSelector({
} else if (key.return) {
const selected = pageConversations[selectedIndex];
if (selected?.conversation.id) {
onSelect(selected.conversation.id);
onSelect(selected.conversation.id, {
summary: selected.conversation.summary ?? undefined,
messageCount: selected.messageCount,
});
}
} else if (key.escape) {
onCancel();

View File

@@ -18,7 +18,11 @@ interface MessageSearchProps {
/** Current conversation ID for "current conv" filter */
conversationId?: string;
/** Callback when user wants to open a conversation */
onOpenConversation?: (agentId: string, conversationId?: string) => void;
onOpenConversation?: (
agentId: string,
conversationId?: string,
searchContext?: { query: string; message: string },
) => void;
}
const VISIBLE_ITEMS = 5;
@@ -347,7 +351,11 @@ export function MessageSearch({
conversation_id?: string;
};
if (msgData.agent_id) {
onOpenConversation(msgData.agent_id, msgData.conversation_id);
const fullText = getMessageText(expandedMessage);
onOpenConversation(msgData.agent_id, msgData.conversation_id, {
query: activeQuery,
message: fullText,
});
}
}
return;

View File

@@ -0,0 +1,154 @@
import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
const SYSTEM_ALERT_OPEN = "<system-alert>";
const SYSTEM_ALERT_CLOSE = "</system-alert>";
const MAX_HISTORY_MESSAGES = 8;
const MAX_MESSAGE_CHARS = 500;
export interface ConversationSwitchContext {
origin:
| "resume-direct"
| "resume-selector"
| "new"
| "clear"
| "search"
| "agent-switch";
conversationId: string;
isDefault: boolean;
summary?: string;
messageCount?: number;
messageHistory?: Message[];
searchQuery?: string;
searchMessage?: string;
agentSwitchContext?: {
name: string;
description?: string;
model: string;
blockCount: number;
};
}
export function buildConversationSwitchAlert(
ctx: ConversationSwitchContext,
): string {
const parts: string[] = [];
if (ctx.origin === "new" || ctx.origin === "clear") {
parts.push(
"New conversation started. This is a fresh conversation thread with no prior messages.",
);
parts.push(`Conversation: ${ctx.conversationId}`);
} else if (ctx.origin === "search") {
parts.push(
`Conversation switched. The user searched for "${ctx.searchQuery}" and jumped to this conversation based on a matching message.`,
);
if (ctx.searchMessage) {
parts.push(`Selected message: "${ctx.searchMessage}"`);
}
pushConversationMeta(parts, ctx);
pushMessageHistory(parts, ctx);
} else if (ctx.origin === "agent-switch" && ctx.agentSwitchContext) {
const a = ctx.agentSwitchContext;
parts.push("Switched to a different agent.");
parts.push(`Agent: ${a.name}`);
if (a.description) {
parts.push(`Description: ${a.description}`);
}
parts.push(
`Model: ${a.model} · ${a.blockCount} memory block${a.blockCount === 1 ? "" : "s"}`,
);
pushMessageHistory(parts, ctx);
parts.push(
"The conversation context has changed entirely — review the in-context messages.",
);
} else if (ctx.isDefault) {
parts.push(
"Switched to the agent's default conversation (the primary, non-isolated message history).",
);
parts.push(
"This conversation is shared across all sessions that don't use explicit conversation IDs.",
);
pushMessageHistory(parts, ctx);
parts.push("Review the in-context messages for full conversation history.");
} else {
const via =
ctx.origin === "resume-selector" ? "/resume selector" : "/resume";
parts.push(`Conversation resumed via ${via}.`);
pushConversationMeta(parts, ctx);
pushMessageHistory(parts, ctx);
parts.push("Review the in-context messages for full conversation history.");
}
return `${SYSTEM_ALERT_OPEN}\n${parts.join("\n")}\n${SYSTEM_ALERT_CLOSE}\n\n`;
}
function pushConversationMeta(
parts: string[],
ctx: ConversationSwitchContext,
): void {
const label = ctx.isDefault ? "default" : ctx.conversationId;
const countSuffix =
ctx.messageCount != null ? ` (${ctx.messageCount} messages)` : "";
parts.push(`Conversation: ${label}${countSuffix}`);
if (ctx.summary) {
parts.push(`Summary: ${ctx.summary}`);
}
}
function extractMessageText(msg: Message): string | null {
const content = (
msg as Message & {
content?: string | Array<{ type?: string; text?: string }>;
}
).content;
if (!content) return null;
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
const texts = content
.filter(
(p): p is { type: string; text: string } =>
p?.type === "text" && !!p.text,
)
.map((p) => p.text.trim())
.filter(Boolean);
return texts.join("\n") || null;
}
return null;
}
function pushMessageHistory(
parts: string[],
ctx: ConversationSwitchContext,
): void {
if (!ctx.messageHistory || ctx.messageHistory.length === 0) return;
const relevant = ctx.messageHistory
.filter(
(m) =>
m.message_type === "user_message" ||
m.message_type === "assistant_message",
)
.slice(-MAX_HISTORY_MESSAGES);
if (relevant.length === 0) return;
parts.push("Recent conversation messages:");
for (const msg of relevant) {
const text = extractMessageText(msg);
if (!text) continue;
const role = msg.message_type === "user_message" ? "user" : "assistant";
const clipped =
text.length > MAX_MESSAGE_CHARS
? `${text.slice(0, MAX_MESSAGE_CHARS)}...`
: text;
parts.push(`[${role}] ${clipped}`);
}
}

View File

@@ -59,6 +59,7 @@ export interface Settings {
reflectionTrigger: "off" | "step-count" | "compaction-event";
reflectionBehavior: "reminder" | "auto-launch";
reflectionStepCount: number;
conversationSwitchAlertEnabled: boolean; // Send system-alert when switching conversations/agents
globalSharedBlockIds: Record<string, string>; // DEPRECATED: kept for backwards compat
profiles?: Record<string, string>; // DEPRECATED: old format, kept for migration
pinnedAgents?: string[]; // DEPRECATED: kept for backwards compat, use pinnedAgentsByServer
@@ -116,6 +117,7 @@ const DEFAULT_SETTINGS: Settings = {
tokenStreaming: false,
showCompactions: false,
enableSleeptime: false,
conversationSwitchAlertEnabled: false,
sessionContextEnabled: true,
memoryReminderInterval: 25, // DEPRECATED: use reflection* fields
reflectionTrigger: "step-count",