ctx-mgmt: ctx session management (dev mode only) (#2415)

This commit is contained in:
Lily Delalande
2025-05-02 16:12:56 -04:00
committed by GitHub
parent 2366a3ad01
commit 8ba40bdccc
19 changed files with 710 additions and 118 deletions

View File

@@ -9,6 +9,8 @@ use goose::message::{
};
use goose::permission::permission_confirmation::PrincipalType;
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata};
use goose::session::info::SessionInfo;
use goose::session::SessionMetadata;
use mcp_core::content::{Annotations, Content, EmbeddedResource, ImageContent, TextContent};
use mcp_core::handler::ToolResultSchema;
use mcp_core::resource::ResourceContents;
@@ -33,7 +35,9 @@ use utoipa::OpenApi;
super::routes::config_management::upsert_permissions,
super::routes::agent::get_tools,
super::routes::reply::confirm_permission,
super::routes::context::manage_context, // Added this path
super::routes::context::manage_context,
super::routes::session::list_sessions,
super::routes::session::get_session_history
),
components(schemas(
super::routes::config_management::UpsertConfigQuery,
@@ -48,6 +52,8 @@ use utoipa::OpenApi;
super::routes::reply::PermissionConfirmationRequest,
super::routes::context::ContextManageRequest,
super::routes::context::ContextManageResponse,
super::routes::session::SessionListResponse,
super::routes::session::SessionHistoryResponse,
Message,
MessageContent,
Content,
@@ -76,6 +82,8 @@ use utoipa::OpenApi;
PermissionLevel,
PrincipalType,
ModelInfo,
SessionInfo,
SessionMetadata,
))
)]
pub struct ApiDoc;

View File

@@ -11,20 +11,41 @@ use axum::{
use goose::message::Message;
use goose::session;
use goose::session::info::{get_session_info, SessionInfo, SortOrder};
use goose::session::SessionMetadata;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize)]
struct SessionListResponse {
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SessionListResponse {
/// List of available session information objects
sessions: Vec<SessionInfo>,
}
#[derive(Serialize)]
struct SessionHistoryResponse {
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SessionHistoryResponse {
/// Unique identifier for the session
session_id: String,
metadata: session::SessionMetadata,
/// Session metadata containing creation time and other details
metadata: SessionMetadata,
/// List of messages in the session conversation
messages: Vec<Message>,
}
#[utoipa::path(
get,
path = "/sessions",
responses(
(status = 200, description = "List of available sessions retrieved successfully", body = SessionListResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
// List all available sessions
async fn list_sessions(
State(state): State<Arc<AppState>>,
@@ -38,6 +59,23 @@ async fn list_sessions(
Ok(Json(SessionListResponse { sessions }))
}
#[utoipa::path(
get,
path = "/sessions/{session_id}",
params(
("session_id" = String, Path, description = "Unique identifier for the session")
),
responses(
(status = 200, description = "Session history retrieved successfully", body = SessionHistoryResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
// Get a specific session's history
async fn get_session_history(
State(state): State<Arc<AppState>>,

View File

@@ -77,9 +77,8 @@ pub struct RedactedThinkingContent {
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
#[derive(ToSchema)]
pub struct FrontendToolRequest {
pub id: String,
#[serde(with = "tool_result_serde")]

View File

@@ -1,10 +1,10 @@
use crate::session::{self, SessionMetadata};
use anyhow::Result;
use serde::Serialize;
use std::cmp::Ordering;
use utoipa::ToSchema;
use crate::session::{self, SessionMetadata};
#[derive(Clone, Serialize)]
#[derive(Clone, Serialize, ToSchema)]
pub struct SessionInfo {
pub id: String,
pub path: String,

View File

@@ -8,6 +8,7 @@ use std::fs::{self, File};
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use utoipa::ToSchema;
fn get_home_dir() -> PathBuf {
choose_app_strategy(crate::config::APP_STRATEGY.clone())
@@ -17,9 +18,10 @@ fn get_home_dir() -> PathBuf {
}
/// Metadata for a session, stored as the first line in the session file
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SessionMetadata {
/// Working directory for the session
#[schema(value_type = String, example = "/home/user/sessions/session1")]
pub working_dir: PathBuf,
/// A short description of the session, typically 3 words or less
pub description: String,

View File

@@ -26,9 +26,8 @@ pub struct Resource {
pub annotations: Option<Annotations>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[derive(ToSchema, Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase", untagged)]
#[derive(ToSchema)]
pub enum ResourceContents {
TextResourceContents {
uri: String,

View File

@@ -452,6 +452,82 @@
}
]
}
},
"/sessions": {
"get": {
"tags": [
"Session Management"
],
"operationId": "list_sessions",
"responses": {
"200": {
"description": "List of available sessions retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionListResponse"
}
}
}
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
},
"/sessions/{session_id}": {
"get": {
"tags": [
"Session Management"
],
"operationId": "get_session_history",
"parameters": [
{
"name": "session_id",
"in": "path",
"description": "Unique identifier for the session",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Session history retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionHistoryResponse"
}
}
}
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"404": {
"description": "Session not found"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
}
},
"components": {
@@ -1372,6 +1448,129 @@
"assistant"
]
},
"SessionHistoryResponse": {
"type": "object",
"required": [
"sessionId",
"metadata",
"messages"
],
"properties": {
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Message"
},
"description": "List of messages in the session conversation"
},
"metadata": {
"$ref": "#/components/schemas/SessionMetadata"
},
"sessionId": {
"type": "string",
"description": "Unique identifier for the session"
}
}
},
"SessionInfo": {
"type": "object",
"required": [
"id",
"path",
"modified",
"metadata"
],
"properties": {
"id": {
"type": "string"
},
"metadata": {
"$ref": "#/components/schemas/SessionMetadata"
},
"modified": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"SessionListResponse": {
"type": "object",
"required": [
"sessions"
],
"properties": {
"sessions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SessionInfo"
},
"description": "List of available session information objects"
}
}
},
"SessionMetadata": {
"type": "object",
"description": "Metadata for a session, stored as the first line in the session file",
"required": [
"working_dir",
"description",
"message_count"
],
"properties": {
"accumulated_input_tokens": {
"type": "integer",
"format": "int32",
"description": "The number of input tokens used in the session. Accumulated across all messages.",
"nullable": true
},
"accumulated_output_tokens": {
"type": "integer",
"format": "int32",
"description": "The number of output tokens used in the session. Accumulated across all messages.",
"nullable": true
},
"accumulated_total_tokens": {
"type": "integer",
"format": "int32",
"description": "The total number of tokens used in the session. Accumulated across all messages (useful for tracking cost over an entire session).",
"nullable": true
},
"description": {
"type": "string",
"description": "A short description of the session, typically 3 words or less"
},
"input_tokens": {
"type": "integer",
"format": "int32",
"description": "The number of input tokens used in the session. Retrieved from the provider's last usage.",
"nullable": true
},
"message_count": {
"type": "integer",
"description": "Number of messages in the session",
"minimum": 0
},
"output_tokens": {
"type": "integer",
"format": "int32",
"description": "The number of output tokens used in the session. Retrieved from the provider's last usage.",
"nullable": true
},
"total_tokens": {
"type": "integer",
"format": "int32",
"description": "The total number of tokens used in the session. Retrieved from the provider's last usage.",
"nullable": true
},
"working_dir": {
"type": "string",
"description": "Working directory for the session",
"example": "/home/user/sessions/session1"
}
}
},
"TextContent": {
"type": "object",
"required": [

View File

@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse } from './types.gen';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -142,4 +142,18 @@ export const manageContext = <ThrowOnError extends boolean = false>(options: Opt
...options?.headers
}
});
};
export const listSessions = <ThrowOnError extends boolean = false>(options?: Options<ListSessionsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ListSessionsResponse, unknown, ThrowOnError>({
url: '/sessions',
...options
});
};
export const getSessionHistory = <ThrowOnError extends boolean = false>(options: Options<GetSessionHistoryData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetSessionHistoryResponse, unknown, ThrowOnError>({
url: '/sessions/{session_id}',
...options
});
};

View File

@@ -292,6 +292,74 @@ export type ResourceContents = {
export type Role = 'user' | 'assistant';
export type SessionHistoryResponse = {
/**
* List of messages in the session conversation
*/
messages: Array<Message>;
metadata: SessionMetadata;
/**
* Unique identifier for the session
*/
sessionId: string;
};
export type SessionInfo = {
id: string;
metadata: SessionMetadata;
modified: string;
path: string;
};
export type SessionListResponse = {
/**
* List of available session information objects
*/
sessions: Array<SessionInfo>;
};
/**
* Metadata for a session, stored as the first line in the session file
*/
export type SessionMetadata = {
/**
* The number of input tokens used in the session. Accumulated across all messages.
*/
accumulated_input_tokens?: number | null;
/**
* The number of output tokens used in the session. Accumulated across all messages.
*/
accumulated_output_tokens?: number | null;
/**
* The total number of tokens used in the session. Accumulated across all messages (useful for tracking cost over an entire session).
*/
accumulated_total_tokens?: number | null;
/**
* A short description of the session, typically 3 words or less
*/
description: string;
/**
* The number of input tokens used in the session. Retrieved from the provider's last usage.
*/
input_tokens?: number | null;
/**
* Number of messages in the session
*/
message_count: number;
/**
* The number of output tokens used in the session. Retrieved from the provider's last usage.
*/
output_tokens?: number | null;
/**
* The total number of tokens used in the session. Retrieved from the provider's last usage.
*/
total_tokens?: number | null;
/**
* Working directory for the session
*/
working_dir: string;
};
export type TextContent = {
annotations?: Annotations | null;
text: string;
@@ -775,6 +843,69 @@ export type ManageContextResponses = {
export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses];
export type ListSessionsData = {
body?: never;
path?: never;
query?: never;
url: '/sessions';
};
export type ListSessionsErrors = {
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ListSessionsResponses = {
/**
* List of available sessions retrieved successfully
*/
200: SessionListResponse;
};
export type ListSessionsResponse = ListSessionsResponses[keyof ListSessionsResponses];
export type GetSessionHistoryData = {
body?: never;
path: {
/**
* Unique identifier for the session
*/
session_id: string;
};
query?: never;
url: '/sessions/{session_id}';
};
export type GetSessionHistoryErrors = {
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Session not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type GetSessionHistoryResponses = {
/**
* Session history retrieved successfully
*/
200: SessionHistoryResponse;
};
export type GetSessionHistoryResponse = GetSessionHistoryResponses[keyof GetSessionHistoryResponses];
export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {});
};

View File

@@ -15,12 +15,15 @@ import { SearchView } from './conversation/SearchView';
import { createRecipe } from '../recipe';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import { fetchSessionDetails } from '../sessions';
import { fetchSessionDetails, generateSessionId } from '../sessions';
import 'react-toastify/dist/ReactToastify.css';
import { useMessageStream } from '../hooks/useMessageStream';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import { Recipe } from '../recipe';
import { ContextManagerProvider, useChatContextManager } from './context_management/ContextManager';
import {
ChatContextManagerProvider,
useChatContextManager,
} from './context_management/ContextManager';
import { ContextLengthExceededHandler } from './context_management/ContextLengthExceededHandler';
import {
Message,
@@ -64,14 +67,14 @@ export default function ChatView({
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
return (
<ContextManagerProvider>
<ChatContextManagerProvider>
<ChatContent
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
</ContextManagerProvider>
</ChatContextManagerProvider>
);
}
@@ -93,7 +96,9 @@ function ChatContent({
const [showGame, setShowGame] = useState(false);
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
const scrollRef = useRef<ScrollAreaHandle>(null);
const {
@@ -108,7 +113,6 @@ function ChatContent({
useEffect(() => {
// Log all messages when the component first mounts
console.log('Initial messages when resuming session:', chat.messages);
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
);
@@ -129,6 +133,7 @@ function ChatContent({
setInput: _setInput,
handleInputChange: _handleInputChange,
handleSubmit: _submitMessage,
updateMessageStreamBody,
} = useMessageStream({
api: getApiUrl('/reply'),
initialMessages: chat.messages,
@@ -159,6 +164,36 @@ function ChatContent({
},
});
// for CLE events -- create a new session id for the next set of messages
useEffect(() => {
// If we're in a continuation session, update the chat ID
if (summarizedThread.length > 0) {
const newSessionId = generateSessionId();
// Update the session ID in the chat object
setChat({
...chat,
id: newSessionId!,
title: `Continued from ${chat.id}`,
messageHistoryIndex: summarizedThread.length,
});
// Update the body used by useMessageStream to send future messages to the new session
if (summarizedThread.length > 0 && updateMessageStreamBody) {
updateMessageStreamBody({
session_id: newSessionId,
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
});
}
}
// only update if summarizedThread length changes from 0 -> 1+
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
summarizedThread.length > 0,
]);
// Listen for make-agent-from-chat event
useEffect(() => {
const handleMakeAgent = async () => {
@@ -201,7 +236,8 @@ function ChatContent({
window.electron.logInfo('Opening recipe editor window');
} catch (error) {
window.electron.logInfo('Failed to create recipe:');
window.electron.logInfo(error.message);
const errorMessage = error instanceof Error ? error.message : String(error);
window.electron.logInfo(errorMessage);
} finally {
setIsGeneratingRecipe(false);
}
@@ -233,25 +269,33 @@ function ChatContent({
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
window.electron.startPowerSaveBlocker();
const customEvent = e as CustomEvent;
const customEvent = e as unknown as CustomEvent;
const content = customEvent.detail?.value || '';
if (content.trim()) {
setLastInteractionTime(Date.now());
if (process.env.ALPHA && summarizedThread.length > 0) {
// First reset the messages with the summary
resetMessagesWithSummary(messages, setMessages);
if (summarizedThread.length > 0) {
// move current `messages` to `ancestorMessages` and `messages` to `summarizedThread`
resetMessagesWithSummary(
messages,
setMessages,
ancestorMessages,
setAncestorMessages,
summaryContent
);
// Then append the new user message
// update the chat with new sessionId
// now call the llm
setTimeout(() => {
append(createUserMessage(content));
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 150); // Small delay to ensure state updates properly
}, 150);
} else {
// Normal flow - just append the message
// Normal flow (existing code)
append(createUserMessage(content));
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
@@ -324,6 +368,8 @@ function ChatContent({
// Create tool responses for all interrupted tool requests
let responseMessage: Message = {
display: true,
sendToLLM: true,
role: 'user',
created: Date.now(),
content: [],
@@ -352,7 +398,7 @@ function ChatContent({
// Filter out standalone tool response messages for rendering
// They will be shown as part of the tool invocation in the assistant message
const filteredMessages = messages.filter((message) => {
const filteredMessages = [...ancestorMessages, ...messages].filter((message) => {
// Only filter out when display is explicitly false
if (message.display === false) return false;
@@ -466,10 +512,13 @@ function ChatContent({
) : (
<>
{/* Only render GooseMessage if it's not a CLE message (and we are not in alpha mode) */}
{process.env.ALPHA && hasContextLengthExceededContent(message) ? (
{process.env.NODE_ENV === 'development' &&
hasContextLengthExceededContent(message) ? (
<ContextLengthExceededHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
/>
) : (
<GooseMessage
@@ -529,7 +578,7 @@ function ChatContent({
</Card>
{showGame && <FlappyGoose onClose={() => setShowGame(false)} />}
{process.env.ALPHA && (
{process.env.NODE_ENV === 'development' && (
<SessionSummaryModal
isOpen={isSummaryModalOpen}
onClose={closeSummaryModal}

View File

@@ -5,21 +5,30 @@ import { useChatContextManager } from './ContextManager';
interface ContextLengthExceededHandlerProps {
messages: Message[];
messageId: string;
chatId: string;
workingDir: string;
}
export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandlerProps> = ({
messages,
messageId,
chatId,
workingDir,
}) => {
const { fetchSummary, summaryContent, isLoadingSummary, errorLoadingSummary, openSummaryModal } =
useChatContextManager();
const {
summaryContent,
isLoadingSummary,
errorLoadingSummary,
openSummaryModal,
handleContextLengthExceeded,
} = useChatContextManager();
const [hasFetchStarted, setHasFetchStarted] = useState(false);
// Find the relevant message to check if it's the latest
const isCurrentMessageLatest =
messageId === messages[messages.length - 1].id ||
messageId === messages[messages.length - 1].created.toString();
messageId === messages[messages.length - 1]?.id ||
messageId === String(messages[messages.length - 1]?.created);
// Only allow interaction for the most recent context length exceeded event
const shouldAllowSummaryInteraction = isCurrentMessageLatest;
@@ -27,31 +36,60 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
// Use a ref to track if we've started the fetch
const fetchStartedRef = useRef(false);
// Function to trigger the async operation properly
const triggerContextLengthExceeded = () => {
setHasFetchStarted(true);
fetchStartedRef.current = true;
// Call the async function without awaiting it in useEffect
handleContextLengthExceeded(messages, chatId, workingDir).catch((err) => {
console.error('Error handling context length exceeded:', err);
});
};
useEffect(() => {
// Automatically fetch summary if conditions are met
if (
!summaryContent &&
!hasFetchStarted &&
shouldAllowSummaryInteraction &&
!fetchStartedRef.current
) {
setHasFetchStarted(true);
fetchStartedRef.current = true;
fetchSummary(messages);
// Use the wrapper function instead of calling the async function directly
triggerContextLengthExceeded();
}
}, [fetchSummary, hasFetchStarted, messages, shouldAllowSummaryInteraction, summaryContent]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
hasFetchStarted,
messages,
shouldAllowSummaryInteraction,
summaryContent,
chatId,
workingDir,
]);
// Handle retry
// Handle retry - Call the async function properly
const handleRetry = () => {
if (!shouldAllowSummaryInteraction) return;
fetchSummary(messages);
// Reset states for retry
setHasFetchStarted(false);
fetchStartedRef.current = false;
// Trigger the process again
triggerContextLengthExceeded();
};
// Render the notification UI
return (
<div className="flex flex-col items-start mt-1 pl-4">
{/* Horizontal line with text in the middle - shown regardless of loading state */}
<div className="relative flex items-center py-2 w-full">
<div className="flex-grow border-t border-gray-300"></div>
<div className="flex-grow border-t border-gray-300"></div>
</div>
{isLoadingSummary && shouldAllowSummaryInteraction ? (
// Only show loading indicator during loading state
// Show loading indicator during loading state
<div className="flex items-center text-xs text-gray-400">
<span className="mr-2">Preparing summary...</span>
<span className="animate-spin h-3 w-3 border-2 border-gray-400 rounded-full border-t-transparent"></span>
@@ -59,15 +97,17 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
) : (
// Show different UI based on whether it's already handled
<>
<span className="text-xs text-gray-400 italic">{'Session summarized'}</span>
<span className="text-xs text-gray-400">{`Your conversation has exceeded the model's context capacity`}</span>
<span className="text-xs text-gray-400">{`Messages above this line remain viewable but are not included in the active context`}</span>
{/* Only show the button if its last message */}
{shouldAllowSummaryInteraction && (
<button
onClick={() => (errorLoadingSummary ? handleRetry() : openSummaryModal())}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
>
{errorLoadingSummary ? 'Retry loading summary' : 'View or edit summary'}
{errorLoadingSummary
? 'Retry loading summary'
: 'View or edit summary (you may continue your conversation based on the summary)'}
</button>
)}
</>

View File

@@ -3,7 +3,7 @@ import { Message } from '../../types/message';
import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index';
// Define the context management interface
interface ContextManagerState {
interface ChatContextManagerState {
summaryContent: string;
summarizedThread: Message[];
isSummaryModalOpen: boolean;
@@ -11,31 +11,73 @@ interface ContextManagerState {
errorLoadingSummary: boolean;
}
interface ContextManagerActions {
interface ChatContextManagerActions {
fetchSummary: (messages: Message[]) => Promise<void>;
updateSummary: (newSummaryContent: string) => void;
resetMessagesWithSummary: (
messages: Message[],
setMessages: (messages: Message[]) => void
setMessages: (messages: Message[]) => void,
ancestorMessages: Message[],
setAncestorMessages: (messages: Message[]) => void,
summaryContent: string
) => void;
openSummaryModal: () => void;
closeSummaryModal: () => void;
hasContextLengthExceededContent: (message: Message) => boolean;
handleContextLengthExceeded: (
messages: Message[],
chatId: string,
workingDir: string
) => Promise<void>;
}
// Create the context
const ContextManagerContext = createContext<
(ContextManagerState & ContextManagerActions) | undefined
const ChatContextManagerContext = createContext<
(ChatContextManagerState & ChatContextManagerActions) | undefined
>(undefined);
// Create the provider component
export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [summaryContent, setSummaryContent] = useState<string>('');
const [summarizedThread, setSummarizedThread] = useState<Message[]>([]);
const [isSummaryModalOpen, setIsSummaryModalOpen] = useState<boolean>(false);
const [isLoadingSummary, setIsLoadingSummary] = useState<boolean>(false);
const [errorLoadingSummary, setErrorLoadingSummary] = useState<boolean>(false);
const handleContextLengthExceeded = async (messages: Message[]): Promise<void> => {
setIsLoadingSummary(true);
setErrorLoadingSummary(false);
try {
// 2. Now get the summary from the backend
const summaryResponse = await manageContextFromBackend({
messages: messages,
manageAction: 'summarize',
});
// Convert API messages to frontend messages
const convertedMessages = summaryResponse.messages.map(
(apiMessage) => convertApiMessageToFrontendMessage(apiMessage, false, true) // do not show to user but send to llm
);
// Extract summary from the first message
const summaryMessage = convertedMessages[0].content[0];
if (summaryMessage.type === 'text') {
const summary = summaryMessage.text;
setSummaryContent(summary);
setSummarizedThread(convertedMessages);
}
setIsLoadingSummary(false);
} catch (err) {
console.error('Error handling context length exceeded:', err);
setErrorLoadingSummary(true);
setIsLoadingSummary(false);
}
};
const fetchSummary = async (messages: Message[]) => {
setIsLoadingSummary(true);
setErrorLoadingSummary(false);
@@ -47,8 +89,8 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = (
});
// Convert API messages to frontend messages
const convertedMessages = response.messages.map((apiMessage) =>
convertApiMessageToFrontendMessage(apiMessage)
const convertedMessages = response.messages.map(
(apiMessage) => convertApiMessageToFrontendMessage(apiMessage, false, true) // do not show to user but send to llm
);
// Extract the summary text from the first message
@@ -101,27 +143,69 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = (
const resetMessagesWithSummary = (
messages: Message[],
setMessages: (messages: Message[]) => void
setMessages: (messages: Message[]) => void,
ancestorMessages: Message[],
setAncestorMessages: (messages: Message[]) => void,
summaryContent: string
) => {
// Update summarizedThread with metadata
const updatedSummarizedThread = summarizedThread.map((msg) => ({
...msg,
display: false,
sendToLLM: true,
}));
// Create a copy of the summarized thread
const updatedSummarizedThread = [...summarizedThread];
// Update list of messages with other metadata
const updatedMessages = messages.map((msg) => ({
...msg,
display: true,
sendToLLM: false,
}));
// Make sure there's at least one message in the summarized thread
if (updatedSummarizedThread.length > 0) {
// Get the first message
const firstMessage = { ...updatedSummarizedThread[0] };
// Make a copy that combines both
const newMessages = [...updatedMessages, ...updatedSummarizedThread];
// Make a copy of the content array
const contentCopy = [...firstMessage.content];
// Assuming the first content item is of type TextContent
if (contentCopy.length > 0 && 'text' in contentCopy[0]) {
// Update the text with the new summary content
contentCopy[0] = {
...contentCopy[0],
text: summaryContent,
};
// Update the first message with the new content
firstMessage.content = contentCopy;
// Update the first message in the thread
updatedSummarizedThread[0] = firstMessage;
}
}
// Update metadata for the summarized thread
const finalUpdatedThread = updatedSummarizedThread.map((msg, index) => ({
...msg,
display: index === 0, // First message has display: true, others false
sendToLLM: true, // All messages have sendToLLM: true
}));
// Update the messages state
setMessages(newMessages);
setMessages(finalUpdatedThread);
// If ancestorMessages already has items, extend it instead of replacing it
if (ancestorMessages.length > 0) {
// Convert current messages to ancestor format
const newAncestorMessages = messages.map((msg) => ({
...msg,
display: true,
sendToLLM: false,
}));
// Append new ancestor messages to existing ones
setAncestorMessages([...ancestorMessages, ...newAncestorMessages]);
} else {
// Initial set of ancestor messages
const newAncestorMessages = messages.map((msg) => ({
...msg,
display: true,
sendToLLM: false,
}));
setAncestorMessages(newAncestorMessages);
}
// Clear the summarized thread and content
setSummarizedThread([]);
@@ -155,14 +239,19 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = (
openSummaryModal,
closeSummaryModal,
hasContextLengthExceededContent,
handleContextLengthExceeded,
};
return <ContextManagerContext.Provider value={value}>{children}</ContextManagerContext.Provider>;
return (
<ChatContextManagerContext.Provider value={value}>
{children}
</ChatContextManagerContext.Provider>
);
};
// Create a hook to use the context
export const useChatContextManager = () => {
const context = useContext(ContextManagerContext);
const context = useContext(ChatContextManagerContext);
if (context === undefined) {
throw new Error('useContextManager must be used within a ContextManagerProvider');
}

View File

@@ -93,6 +93,12 @@ export function SessionSummaryModal({
ref={textareaRef}
defaultValue={summaryContent}
className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 text-sm w-full min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
style={{
textRendering: 'optimizeLegibility',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
transform: 'translateZ(0)', // Force hardware acceleration
}}
/>
</div>
);

View File

@@ -49,10 +49,14 @@ export async function manageContextFromBackend({
}
// Function to convert API Message to frontend Message
export function convertApiMessageToFrontendMessage(apiMessage: ApiMessage): FrontendMessage {
export function convertApiMessageToFrontendMessage(
apiMessage: ApiMessage,
display?: boolean,
sendToLLM?: boolean
): FrontendMessage {
return {
display: false,
sendToLLM: false,
display: display ?? true,
sendToLLM: sendToLLM ?? true,
id: generateId(),
role: apiMessage.role,
created: apiMessage.created,

View File

@@ -35,8 +35,8 @@ const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSess
setIsLoading(true);
setError(null);
try {
const response = await fetchSessions();
setSessions(response.sessions);
const sessions = await fetchSessions();
setSessions(sessions);
} catch (err) {
console.error('Failed to load sessions:', err);
setError('Failed to load sessions. Please try again later.');

View File

@@ -47,6 +47,8 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const handleResumeSession = () => {
if (selectedSession) {
console.log('Selected session object:', JSON.stringify(selectedSession, null, 2));
// Get the working directory from the session metadata
const workingDir = selectedSession.metadata.working_dir;

View File

@@ -121,6 +121,9 @@ export interface UseMessageStreamHelpers {
/** Add a tool result to a tool call */
addToolResult: ({ toolCallId, result }: { toolCallId: string; result: unknown }) => void;
/** Modify body (session id and/or work dir mid-stream) **/
updateMessageStreamBody?: (newBody: object) => void;
}
/**
@@ -148,6 +151,14 @@ export function useMessageStream({
fallbackData: initialMessages,
});
// expose a way to update the body so we can update the session id when CLE occurs
const updateMessageStreamBody = useCallback((newBody: object) => {
extraMetadataRef.current.body = {
...extraMetadataRef.current.body,
...newBody,
};
}, []);
// Keep the latest messages in a ref
const messagesRef = useRef<Message[]>(messages || []);
useEffect(() => {
@@ -505,5 +516,6 @@ export function useMessageStream({
handleSubmit,
isLoading: isLoading || false,
addToolResult,
updateMessageStreamBody,
};
}

View File

@@ -1,5 +1,6 @@
import { getApiUrl, getSecretKey } from './config';
import { Message } from './types/message';
import { getSessionHistory, listSessions, SessionInfo } from './api';
import { convertApiMessageToFrontendMessage } from './components/context_management';
export interface SessionMetadata {
description: string;
@@ -54,36 +55,40 @@ export function generateSessionId(): string {
* Fetches all available sessions from the API
* @returns Promise with sessions data
*/
export async function fetchSessions(): Promise<SessionsResponse> {
/**
* Fetches all available sessions from the API
* @returns Promise with an array of Session objects
*/
export async function fetchSessions(): Promise<Session[]> {
try {
const response = await fetch(getApiUrl('/sessions'), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
});
const response = await listSessions<true>();
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.status} ${response.statusText}`);
// Check if the response has the expected structure
if (response && response.data && response.data.sessions) {
// Since the API returns SessionInfo, we need to convert to Session
const sessions = response.data.sessions
.filter(
(sessionInfo: SessionInfo) =>
sessionInfo.metadata && sessionInfo.metadata.description !== ''
)
.map(
(sessionInfo: SessionInfo): Session => ({
id: sessionInfo.id,
path: sessionInfo.path,
modified: sessionInfo.modified,
metadata: ensureWorkingDir(sessionInfo.metadata),
})
);
// order sessions by 'modified' date descending
sessions.sort(
(a: Session, b: Session) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
);
return sessions;
} else {
throw new Error('Unexpected response format from listSessions');
}
// TODO: remove this logic once everyone migrates to the new sessions format
// for now, filter out sessions whose description is empty (old CLI sessions)
const rawSessions = await response.json();
const sessions = rawSessions.sessions
.filter((session: Session) => session.metadata.description !== '')
.map((session: Session) => ({
...session,
metadata: ensureWorkingDir(session.metadata),
}));
// order sessions by 'modified' date descending
sessions.sort(
(a: Session, b: Session) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
);
return { sessions };
} catch (error) {
console.error('Error fetching sessions:', error);
throw error;
@@ -97,22 +102,17 @@ export async function fetchSessions(): Promise<SessionsResponse> {
*/
export async function fetchSessionDetails(sessionId: string): Promise<SessionDetails> {
try {
const response = await fetch(getApiUrl(`/sessions/${sessionId}`), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
const response = await getSessionHistory<true>({
path: { session_id: sessionId },
});
if (!response.ok) {
throw new Error(`Failed to fetch session details: ${response.status} ${response.statusText}`);
}
const details = await response.json();
// Convert the SessionHistoryResponse to a SessionDetails object
return {
...details,
metadata: ensureWorkingDir(details.metadata),
session_id: response.data.sessionId,
metadata: ensureWorkingDir(response.data.metadata),
messages: response.data.messages.map((message) =>
convertApiMessageToFrontendMessage(message, true, true)
), // slight diffs between backend and frontend Message obj
};
} catch (error) {
console.error(`Error fetching session details for ${sessionId}:`, error);