feat(harness): add system alert for conversation switching (#910)
This commit is contained in:
108
src/cli/App.tsx
108
src/cli/App.tsx
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
154
src/cli/helpers/conversationSwitchAlert.ts
Normal file
154
src/cli/helpers/conversationSwitchAlert.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user