feat: agent chat history

This commit is contained in:
Carl-Robert Linnupuu 2026-02-06 16:03:07 +00:00
parent 01757c4af2
commit b456b53752
30 changed files with 2149 additions and 171 deletions

View file

@ -28,6 +28,7 @@ import javax.swing.JPanel;
public class ChatToolWindowScrollablePanel extends ScrollablePanel {
private final Map<UUID, JPanel> visibleMessagePanels = new HashMap<>();
private boolean landingViewVisible;
public ChatToolWindowScrollablePanel() {
super(new VerticalStackLayout());
@ -36,6 +37,7 @@ public class ChatToolWindowScrollablePanel extends ScrollablePanel {
public void displayLandingView(JComponent landingView) {
clearAll();
add(landingView);
landingViewVisible = true;
if (ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT)
== ServiceType.PROXYAI
&& !CredentialsStore.INSTANCE.isCredentialSet(CredentialKey.CodeGptApiKey.INSTANCE)) {
@ -73,6 +75,9 @@ public class ChatToolWindowScrollablePanel extends ScrollablePanel {
}
public JPanel addMessage(UUID messageId) {
if (landingViewVisible) {
clearAll();
}
var messageWrapper = new JPanel();
messageWrapper.setLayout(new BoxLayout(messageWrapper, BoxLayout.PAGE_AXIS));
add(messageWrapper);
@ -86,8 +91,15 @@ public class ChatToolWindowScrollablePanel extends ScrollablePanel {
visibleMessagePanels.remove(messageId);
}
public void clearLandingViewIfVisible() {
if (landingViewVisible) {
clearAll();
}
}
public void clearAll() {
visibleMessagePanels.clear();
landingViewVisible = false;
removeAll();
update();
}

View file

@ -1,6 +1,7 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.core.agent.AIAgentService
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import ai.koog.prompt.executor.clients.LLMClientException
@ -8,8 +9,11 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.agent.history.AgentCheckpointHistoryService
import ee.carlrobert.codegpt.agent.history.CheckpointRef
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.toolwindow.agent.AgentToolWindowContentManager
import ee.carlrobert.codegpt.ui.textarea.header.tag.McpTagDetails
import kotlinx.coroutines.*
@ -19,6 +23,17 @@ import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.Path
internal fun interface AgentRuntimeFactory {
fun create(
project: Project,
checkpointStorage: JVMFilePersistenceStorageProvider,
provider: ServiceType,
events: AgentEvents,
sessionId: String,
pendingMessages: ConcurrentHashMap<String, ArrayDeque<MessageWithContext>>,
): AIAgentService<MessageWithContext, String, out AIAgent<MessageWithContext, String>>
}
@Service(Service.Level.PROJECT)
class AgentService(private val project: Project) {
@ -26,11 +41,31 @@ class AgentService(private val project: Project) {
private val logger = thisLogger()
}
private data class SessionRuntime(
val service: AIAgentService<MessageWithContext, String, out AIAgent<MessageWithContext, String>>,
val provider: ServiceType,
val modelId: String,
val selectedServerIds: Set<String>,
val events: AgentEvents
)
private val sessionJobs = ConcurrentHashMap<String, Job>()
private val pendingMessages = ConcurrentHashMap<String, ArrayDeque<MessageWithContext>>()
private val sessionAgents = ConcurrentHashMap<String, AIAgent<MessageWithContext, String>>()
private val sessionRuntimes = ConcurrentHashMap<String, SessionRuntime>()
internal var runtimeFactory: AgentRuntimeFactory = AgentRuntimeFactory { p, storage, provider, events, sid, pending ->
ProxyAIAgent.createService(
project = p,
checkpointStorage = storage,
provider = provider,
events = events,
sessionId = sid,
pendingMessages = pending
)
}
private val checkpointStorage =
JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai"))
private val historyService = project.service<AgentCheckpointHistoryService>()
private val _queuedMessageProcessed = MutableSharedFlow<String>()
val queuedMessageProcessed = _queuedMessageProcessed.asSharedFlow()
@ -40,19 +75,28 @@ class AgentService(private val project: Project) {
}
suspend fun getCheckpoint(sessionId: String): AgentCheckpointData? {
val prevAgentId = sessionAgents[sessionId]?.id ?: return null
return runCatching {
checkpointStorage.getCheckpoints(prevAgentId)
.filter { it.nodePath != "tombstone" }
.maxByOrNull { it.createdAt }
}.onFailure { ex ->
val sessionInfo = sessionId.let { " session=$it" }
logger.error(
"Agent checkpoints: failed to load for$sessionInfo agentId=$prevAgentId " +
"error=${ex.message}",
ex
)
}.getOrNull()
val session =
project.service<AgentToolWindowContentManager>().getSession(sessionId) ?: return null
val ref = session.resumeCheckpointRef
if (ref != null) {
val checkpoint = runCatching { historyService.loadResumeCheckpoint(ref) }
.onFailure { ex -> logCheckpointLoadFailure(sessionId, ref.agentId, ex) }
.getOrNull()
if (checkpoint != null) {
return checkpoint
}
}
val runtimeAgentId = session.runtimeAgentId ?: return null
val latest = runCatching { historyService.loadLatestResumeCheckpoint(runtimeAgentId) }
.onFailure { ex -> logCheckpointLoadFailure(sessionId, runtimeAgentId, ex) }
.getOrNull()
?: return null
project.service<AgentToolWindowContentManager>()
.setResumeCheckpointRef(sessionId, CheckpointRef(runtimeAgentId, latest.checkpointId))
return latest
}
fun submitMessage(message: MessageWithContext, events: AgentEvents, sessionId: String) {
@ -64,17 +108,19 @@ class AgentService(private val project: Project) {
}
val provider = service<ModelSelectionService>().getServiceForFeature(FeatureType.AGENT)
val previousCheckpoint = runBlocking { getCheckpoint(sessionId) }
val contentManager = project.service<AgentToolWindowContentManager>()
val runtimeAgentId = contentManager.getSession(sessionId)?.runtimeAgentId
val runtime = ensureSessionRuntime(sessionId, provider, events)
val agent = ProxyAIAgent.create(
project,
checkpointStorage,
previousCheckpoint,
provider,
events,
sessionId,
pendingMessages
)
val agent = runCatching {
runBlocking {
runtime.service.createAgent(id = runtimeAgentId)
}
}.onFailure { ex ->
logger.warn("Failed to create managed agent for session=$sessionId", ex)
}.getOrNull() ?: return
contentManager.setRuntimeAgentId(sessionId, agent.id)
sessionAgents[sessionId] = agent
sessionJobs[sessionId] = CoroutineScope(Dispatchers.IO).launch {
try {
@ -86,6 +132,15 @@ class AgentService(private val project: Project) {
} catch (ex: Exception) {
logger.error(ex)
} finally {
refreshSessionResumeCheckpoint(sessionId, agent.id)
sessionAgents.remove(sessionId, agent)
runCatching { runtime.service.removeAgentWithId(agent.id) }
.onFailure { ex ->
logger.warn(
"Failed removing managed agent for session=$sessionId agentId=${agent.id}",
ex
)
}
sessionJobs.remove(sessionId)
}
}
@ -100,6 +155,13 @@ class AgentService(private val project: Project) {
cancelCurrentRun(sessionId)
pendingMessages.remove(sessionId)
sessionAgents.remove(sessionId)
sessionRuntimes.remove(sessionId)?.let { runtime ->
runCatching {
runBlocking { runtime.service.closeAll() }
}.onFailure { ex ->
logger.warn("Failed closing managed agent service for session=$sessionId", ex)
}
}
project.service<AgentMcpContextService>().clear(sessionId)
}
@ -138,4 +200,74 @@ class AgentService(private val project: Project) {
project.service<AgentMcpContextService>()
.update(sessionId, conversationId, selectedServerIds)
}
private suspend fun refreshSessionResumeCheckpoint(sessionId: String, agentId: String) {
val ref = runCatching {
historyService.loadLatestResumeCheckpoint(agentId)
?.let { CheckpointRef(agentId, it.checkpointId) }
}.onFailure { ex ->
logger.warn(
"Agent checkpoints: failed to refresh session checkpoint session=$sessionId " +
"agentId=$agentId error=${ex.message}",
ex
)
}.getOrNull()
if (ref != null) {
project.service<AgentToolWindowContentManager>().setResumeCheckpointRef(sessionId, ref)
}
}
private fun ensureSessionRuntime(
sessionId: String,
provider: ServiceType,
events: AgentEvents
): SessionRuntime {
val modelId = service<ModelSelectionService>().getAgentModel().id
val selectedServerIds = project.service<AgentMcpContextService>()
.get(sessionId)
?.selectedServerIds
?: emptySet()
val existing = sessionRuntimes[sessionId]
if (existing != null &&
existing.provider == provider &&
existing.modelId == modelId &&
existing.selectedServerIds == selectedServerIds &&
existing.events === events
) {
return existing
}
existing?.let { stale ->
runCatching { runBlocking { stale.service.closeAll() } }
.onFailure { ex ->
logger.warn("Failed closing stale managed service for session=$sessionId", ex)
}
}
val created = SessionRuntime(
service = runtimeFactory.create(
project = project,
checkpointStorage = checkpointStorage,
provider = provider,
events = events,
sessionId = sessionId,
pendingMessages = pendingMessages
),
provider = provider,
modelId = modelId,
selectedServerIds = selectedServerIds,
events = events
)
sessionRuntimes[sessionId] = created
return created
}
private fun logCheckpointLoadFailure(sessionId: String, agentId: String, ex: Throwable) {
val sessionInfo = sessionId.let { " session=$it" }
logger.error(
"Agent checkpoints: failed to load for$sessionInfo agentId=$agentId error=${ex.message}",
ex
)
}
}

View file

@ -1,14 +1,19 @@
package ee.carlrobert.codegpt.agent
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
import java.util.UUID
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.*
@Serializable
class MessageWithContext(
val text: String,
val tags: List<TagDetails>,
@Transient
val tags: List<TagDetails> = emptyList(),
val uiVisible: Boolean = true,
val uiText: String = text
) {
@Transient
val id: UUID = UUID.randomUUID()
}

View file

@ -1,13 +0,0 @@
package ee.carlrobert.codegpt.agent
import kotlinx.serialization.Serializable
@Serializable
data class MessageWithContextSnapshot(
val text: String,
val tagNames: List<String>
)
fun MessageWithContext.toSnapshot(): MessageWithContextSnapshot {
return MessageWithContextSnapshot(text, tags.map { it.name })
}

View file

@ -1,6 +1,7 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.core.agent.AIAgentService
import ai.koog.agents.core.agent.GraphAIAgentService
import ai.koog.agents.core.agent.config.AIAgentConfig
import ai.koog.agents.core.agent.context.RollbackStrategy
import ai.koog.agents.core.tools.Tool
@ -11,13 +12,12 @@ import ai.koog.agents.features.eventHandler.feature.handleEvents
import ai.koog.agents.features.tokenizer.feature.MessageTokenizer
import ai.koog.agents.features.tracing.feature.Tracing
import ai.koog.agents.features.tracing.writer.TraceFeatureMessageLogWriter
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.agents.snapshot.feature.Persistence
import ai.koog.agents.snapshot.feature.persistence
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.message.Message
import ai.koog.prompt.message.RequestMetaInfo
import ai.koog.prompt.streaming.StreamFrame
import ai.koog.prompt.tokenizer.Tokenizer
import com.intellij.openapi.components.service
@ -40,12 +40,13 @@ import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.BashPayload
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalType
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.datetime.Clock
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.nio.file.Files
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.typeOf
data class ToolError(val message: String)
@ -54,9 +55,8 @@ object ProxyAIAgent {
private const val INSTRUCTION_FILE_NAME = "PROXYAI.md"
private val logger = KotlinLogging.logger { }
private val messageWithContextSnapshotType = typeOf<MessageWithContextSnapshot>()
private fun searchForInstructions(projectPath: String?): String? {
internal fun loadProjectInstructions(projectPath: String?): String? {
if (projectPath == null) return null
val projectRoot = Paths.get(projectPath)
if (!Files.exists(projectRoot)) return null
@ -76,32 +76,29 @@ object ProxyAIAgent {
return null
}
fun create(
fun createService(
project: Project,
checkpointStorage: JVMFilePersistenceStorageProvider,
previousCheckpoint: AgentCheckpointData?,
provider: ServiceType,
events: AgentEvents,
sessionId: String,
pendingMessages: ConcurrentHashMap<String, ArrayDeque<MessageWithContext>>,
): AIAgent<MessageWithContext, String> {
): GraphAIAgentService<MessageWithContext, String> {
val modelSelection =
service<ModelSelectionService>().getModelSelectionForFeature(FeatureType.AGENT)
val skills = project.service<SkillDiscoveryService>().listSkills()
val stream = provider != ServiceType.CUSTOM_OPENAI
val projectInstructions = searchForInstructions(project.basePath)
val projectInstructions = loadProjectInstructions(project.basePath)
val executor = AgentFactory.createExecutor(provider, events)
val pendingMessageQueue = pendingMessages.getOrPut(sessionId) { ArrayDeque() }
val hookManager = HookManager(project)
val toolRegistry = createToolRegistry(project, events, sessionId, provider, hookManager)
val agentModel = service<ModelSelectionService>().getAgentModel()
val agent = AIAgent(
return AIAgentService<MessageWithContext, String>(
promptExecutor = executor,
strategy = SingleRunStrategyProvider().build(
project,
executor,
projectInstructions,
previousCheckpoint,
pendingMessageQueue,
HistoryCompressionConfig(
isLimitExceeded = buildHistoryTooBigPredicate(computeAvailableInput(agentModel)),
@ -122,6 +119,20 @@ object ProxyAIAgent {
skills
)
)
projectInstructions?.let {
message(
Message.User(
content = it,
metaInfo = RequestMetaInfo(
Clock.System.now(),
JsonObject(buildMap<String, JsonPrimitive> {
put("cacheable", JsonPrimitive(true))
})
)
)
)
}
},
model = agentModel,
maxAgentIterations = 100
@ -135,7 +146,8 @@ object ProxyAIAgent {
}
install(Persistence) {
storage = checkpointStorage
rollbackStrategy = RollbackStrategy.Default
enableAutomaticPersistence = true
rollbackStrategy = RollbackStrategy.MessageHistoryOnly
}
install(MessageTokenizer) {
tokenizer = object : Tokenizer {
@ -159,25 +171,6 @@ object ProxyAIAgent {
}
onNodeExecutionCompleted { ctx ->
val input = ctx.input
val (lastInput, lastInputType) = if (input is MessageWithContext) {
val snapshot = input.toSnapshot()
snapshot to messageWithContextSnapshotType
} else {
input to ctx.inputType
}
val checkpoint = ctx.context.persistence().createCheckpoint(
agentContext = ctx.context,
nodePath = ctx.context.executionInfo.path(),
lastInput = lastInput,
lastInputType = lastInputType,
checkpointId = ctx.context.runId,
version = 0L
)
checkpoint?.checkpointId ?: return@onNodeExecutionCompleted
if (stream) return@onNodeExecutionCompleted
(ctx.output as? List<*>)?.forEach { msg ->
(msg as? Message.Assistant)?.let {
events.onTextReceived(it.content)
@ -264,7 +257,6 @@ object ProxyAIAgent {
}
}
}
return agent
}
private fun createToolRegistry(

View file

@ -4,6 +4,7 @@ import ee.carlrobert.codegpt.agent.tools.*
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalType
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
enum class ToolName(val id: String, val aliases: Set<String> = emptySet()) {
READ("Read"),
@ -22,34 +23,6 @@ enum class ToolName(val id: String, val aliases: Set<String> = emptySet()) {
ASK_USER_QUESTION("AskUserQuestion"),
TODO_WRITE("TodoWrite", setOf("TodoWriteTool")),
EXIT("Exit");
companion object {
val HOOK_AWARE = setOf(
READ,
WRITE,
EDIT,
BASH,
TASK,
INTELLIJ_SEARCH,
WEB_SEARCH,
MCP,
RESOLVE_LIBRARY_ID,
GET_LIBRARY_DOCS,
LOAD_SKILL,
ASK_USER_QUESTION,
TODO_WRITE,
BASH_OUTPUT,
KILL_SHELL
)
fun isHookAware(toolId: String): Boolean {
return fromId(toolId) in HOOK_AWARE
}
fun fromId(toolId: String): ToolName? {
return entries.find { it.id == toolId }
}
}
}
data class ToolSpec<TArgs, TResult>(
@ -191,4 +164,34 @@ object ToolSpecs {
fun approvalTypeFor(toolName: String): ToolApprovalType {
return find(toolName)?.approvalType ?: ToolApprovalType.GENERIC
}
fun decodeArgsOrNull(
json: Json,
toolName: String,
payload: String
): Any? {
return decodeOrNull(json, find(toolName)?.argsSerializer, payload)
}
fun decodeResultOrNull(
json: Json,
toolName: String,
payload: String
): Any? {
return decodeOrNull(json, find(toolName)?.resultSerializer, payload)
}
@Suppress("UNCHECKED_CAST")
private fun decodeOrNull(
json: Json,
serializer: KSerializer<*>?,
payload: String
): Any? {
if (serializer == null || payload.isBlank()) {
return null
}
return runCatching {
json.decodeFromString(serializer as KSerializer<Any>, payload)
}.getOrNull()
}
}

View file

@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.SerializationException
import kotlin.math.pow
import kotlin.random.Random
import kotlin.time.Duration
@ -53,12 +54,13 @@ class RetryingPromptExecutor(
tools: List<ToolDescriptor>
): Flow<StreamFrame> {
var attempt = 1
var refinedPrompt = prompt
var hasReceivedData: Boolean
fun createStream(attemptNum: Int): Flow<StreamFrame> {
hasReceivedData = false
return delegate.executeStreaming(prompt, model, tools)
return delegate.executeStreaming(refinedPrompt, model, tools)
.onEach { hasReceivedData = true }
.catch { error ->
val shouldRetry = !hasReceivedData && attemptNum < retryPolicy.maxAttempts
@ -111,6 +113,10 @@ class RetryingPromptExecutor(
} else {
throw ce
}
} catch (_: SerializationException) {
refinedPrompt = prompt.copy(messages = prompt.messages.dropLast(1))
currentAttempt++
continue
}
}
}

View file

@ -0,0 +1,106 @@
package ee.carlrobert.codegpt.agent.history
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.llm.client.openai.completion.response.ToolCall
import ee.carlrobert.llm.client.openai.completion.response.ToolFunctionResponse
import ai.koog.prompt.message.Message as PromptMessage
object AgentCheckpointConversationMapper {
fun toConversation(
checkpoint: AgentCheckpointData,
projectInstructions: String?
): Conversation {
val conversation = Conversation()
val history = checkpoint.messageHistory.filterNot { it is PromptMessage.System }
var currentPrompt: String? = null
val response = StringBuilder()
val toolCalls = mutableListOf<ToolCall>()
val toolResults = LinkedHashMap<String, String>()
var syntheticToolIdIndex = 0
fun flushTurn() {
val prompt = currentPrompt?.trim().orEmpty()
if (prompt.isBlank()) {
return
}
val uiMessage = Message(prompt)
uiMessage.response = response.toString().trim()
if (toolCalls.isNotEmpty()) {
uiMessage.toolCalls = ArrayList(toolCalls)
}
if (toolResults.isNotEmpty()) {
uiMessage.toolCallResults = LinkedHashMap(toolResults)
}
conversation.addMessage(uiMessage)
currentPrompt = null
response.setLength(0)
toolCalls.clear()
toolResults.clear()
}
history.forEach { msg ->
when (msg) {
is PromptMessage.User -> {
val text = msg.content.trim()
if (shouldHideInAgentToolWindow(msg, projectInstructions)) {
return@forEach
}
flushTurn()
currentPrompt = text
}
is PromptMessage.Assistant -> appendAssistant(response, msg.content)
is PromptMessage.Reasoning -> appendAssistant(response, msg.content)
is PromptMessage.Tool.Call -> {
if (currentPrompt != null) {
val callId =
msg.id?.takeIf { it.isNotBlank() }
?: "tool-call-${++syntheticToolIdIndex}"
toolCalls.add(
ToolCall(
null,
callId,
"function",
ToolFunctionResponse(msg.tool, msg.content.trim())
)
)
}
}
is PromptMessage.Tool.Result -> {
if (currentPrompt != null) {
val callId = msg.id?.takeIf { it.isNotBlank() }
?: toolCalls.lastOrNull()?.id
?: "tool-call-${++syntheticToolIdIndex}"
val prior = toolResults[callId]
val merged = if (prior.isNullOrBlank()) msg.content.trim() else {
"$prior\n\n${msg.content.trim()}"
}
toolResults[callId] = merged
}
}
else -> Unit
}
}
flushTurn()
return conversation
}
private fun appendAssistant(sb: StringBuilder, content: String) {
val text = content.trim()
if (text.isBlank()) {
return
}
if (sb.isNotEmpty()) {
sb.append("\n\n")
}
sb.append(text)
}
}

View file

@ -0,0 +1,235 @@
package ee.carlrobert.codegpt.agent.history
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.agents.snapshot.feature.isTombstone
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.nio.file.Files
import kotlin.io.path.Path
import ai.koog.prompt.message.Message as PromptMessage
@Service(Service.Level.PROJECT)
class AgentCheckpointHistoryService(project: Project) {
companion object {
private val logger = thisLogger()
}
private val root = Path(project.basePath ?: "", ".proxyai")
private val checkpointsRoot = root.resolve("checkpoints")
private val storage = JVMFilePersistenceStorageProvider(root)
@Volatile
private var cachedSummaries: List<AgentHistoryThreadSummary>? = null
suspend fun listThreads(limit: Int? = null): List<AgentHistoryThreadSummary> =
withContext(Dispatchers.IO) {
val summaries = loadThreadSummaries()
return@withContext if (limit == null) summaries else summaries.take(limit)
}
suspend fun listThreadsPage(
query: String,
offset: Int,
limit: Int,
refresh: Boolean = false
): AgentHistoryPage = withContext(Dispatchers.IO) {
val safeOffset = offset.coerceAtLeast(0)
val safeLimit = limit.coerceAtLeast(1)
val summaries = getCachedSummaries(refresh)
val filtered = filterSummaries(summaries, query)
if (safeOffset >= filtered.size) {
return@withContext AgentHistoryPage(emptyList(), false, filtered.size)
}
val endIndex = (safeOffset + safeLimit).coerceAtMost(filtered.size)
AgentHistoryPage(
items = filtered.subList(safeOffset, endIndex),
hasMore = endIndex < filtered.size,
total = filtered.size
)
}
suspend fun loadCheckpoint(ref: CheckpointRef): AgentCheckpointData? =
withContext(Dispatchers.IO) {
val checkpoints = storage.getCheckpoints(ref.agentId)
.filterNot { it.isTombstone() }
checkpoints.firstOrNull { it.checkpointId == ref.checkpointId }
}
suspend fun loadResumeCheckpoint(ref: CheckpointRef): AgentCheckpointData? =
withContext(Dispatchers.IO) {
val checkpoints = storage.getCheckpoints(ref.agentId)
.filterNot { it.isTombstone() }
.sortedByDescending { it.createdAt }
if (checkpoints.isEmpty()) return@withContext null
val preferred = checkpoints.firstOrNull { it.checkpointId == ref.checkpointId }
if (preferred == null) {
return@withContext checkpoints.firstOrNull { it.isResumableNode() }
}
if (preferred.isResumableNode()) {
return@withContext preferred
}
checkpoints
.asSequence()
.filter { it.createdAt <= preferred.createdAt }
.firstOrNull { it.isResumableNode() }
?: checkpoints.firstOrNull { it.isResumableNode() }
}
suspend fun loadLatestResumeCheckpoint(agentId: String): AgentCheckpointData? =
withContext(Dispatchers.IO) {
val checkpoints = storage.getCheckpoints(agentId)
.filterNot { it.isTombstone() }
checkpoints
.filter { it.isResumableNode() }
.maxByOrNull { it.createdAt }
?: checkpoints.maxByOrNull { it.createdAt }
}
private suspend fun buildSummary(agentId: String): AgentHistoryThreadSummary? {
val checkpoints = storage.getCheckpoints(agentId)
.filterNot { it.isTombstone() }
if (checkpoints.isEmpty()) {
return null
}
val latest = checkpoints.maxBy { it.createdAt }
val status = when (latest.nodePath.substringAfterLast('/')) {
"__finish__" -> ThreadStatus.COMPLETED
"__start__" -> ThreadStatus.UNKNOWN
else -> ThreadStatus.PARTIAL
}
val title = extractThreadTitle(latest.messageHistory)
val lastAssistant = latest.messageHistory
.asReversed()
.firstNotNullOfOrNull { msg ->
when (msg) {
is PromptMessage.Assistant -> toSingleLine(msg.content).takeIf { it.isNotBlank() }
is PromptMessage.Reasoning -> toSingleLine(msg.content).takeIf { it.isNotBlank() }
else -> null
}
} ?: ""
return AgentHistoryThreadSummary(
agentId = agentId,
latest = CheckpointRef(agentId, latest.checkpointId),
latestCreatedAt = latest.createdAt,
status = status,
runCount = checkpoints.size,
title = title.take(80),
preview = lastAssistant.take(160)
)
}
private fun extractThreadTitle(history: List<PromptMessage>): String {
val todoWriteTitle = history
.asReversed()
.firstNotNullOfOrNull { message ->
if (message !is PromptMessage.Tool.Call || !isTodoWriteTool(message.tool)) {
return@firstNotNullOfOrNull null
}
extractTodoWriteTitle(message.content)
}
if (!todoWriteTitle.isNullOrBlank()) {
return toSingleLine(todoWriteTitle)
}
return history
.asReversed()
.firstNotNullOfOrNull { message ->
val user = message as? PromptMessage.User ?: return@firstNotNullOfOrNull null
if (shouldHideInAgentToolWindow(user)) {
return@firstNotNullOfOrNull null
}
user.content
.let(::toSingleLine)
.takeIf { it.isNotBlank() }
} ?: "Recovered conversation"
}
private fun isTodoWriteTool(toolName: String): Boolean {
return toolName.equals("TodoWrite", ignoreCase = true) ||
toolName.equals("TodoWriteTool", ignoreCase = true)
}
private fun extractTodoWriteTitle(rawArgs: String): String? {
val args = runCatching {
Json.parseToJsonElement(rawArgs).jsonObject
}.getOrNull() ?: return null
return args["title"]?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotBlank() }
}
private fun toSingleLine(value: String): String {
return value.replace("\\s+".toRegex(), " ").trim()
}
private fun AgentCheckpointData.isResumableNode(): Boolean {
return nodePath.substringAfterLast('/') != "__finish__"
}
private suspend fun loadThreadSummaries(): List<AgentHistoryThreadSummary> {
if (!Files.exists(checkpointsRoot)) {
return emptyList()
}
val agentIds = withContext(Dispatchers.IO) {
Files.list(checkpointsRoot)
}.use { stream ->
stream
.filter { Files.isDirectory(it) }
.map { it.fileName.toString() }
.toList()
}
return agentIds.mapNotNull { agentId ->
runCatching { buildSummary(agentId) }
.onFailure { logger.warn("Failed to load checkpoints for agentId=$agentId", it) }
.getOrNull()
}.sortedByDescending { it.latestCreatedAt }
}
private suspend fun getCachedSummaries(refresh: Boolean): List<AgentHistoryThreadSummary> {
if (!refresh) {
synchronized(this) {
cachedSummaries?.let { return it }
}
}
val loaded = loadThreadSummaries()
synchronized(this) {
if (refresh || cachedSummaries == null) {
cachedSummaries = loaded
}
return cachedSummaries ?: loaded
}
}
private fun filterSummaries(
summaries: List<AgentHistoryThreadSummary>,
query: String
): List<AgentHistoryThreadSummary> {
val normalized = query.trim()
if (normalized.isBlank()) {
return summaries
}
return summaries.filter { item ->
item.title.contains(normalized, ignoreCase = true) ||
item.preview.contains(normalized, ignoreCase = true) ||
item.agentId.contains(normalized, ignoreCase = true)
}
}
}

View file

@ -0,0 +1,36 @@
package ee.carlrobert.codegpt.agent.history
import ai.koog.prompt.message.Message as PromptMessage
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
private const val CACHEABLE_METADATA_KEY = "cacheable"
internal fun shouldHideInAgentToolWindow(
message: PromptMessage.User,
projectInstructions: String? = null
): Boolean {
return isCacheableInstructionMessage(message) ||
isProjectInstructionsMessage(message.content, projectInstructions)
}
private fun isCacheableInstructionMessage(message: PromptMessage.User): Boolean {
val cacheable = message.metaInfo.metadata
?.get(CACHEABLE_METADATA_KEY)
?.jsonPrimitive
?: return false
return cacheable.booleanOrNull ?: (cacheable.contentOrNull?.equals("true", ignoreCase = true) == true)
}
private fun isProjectInstructionsMessage(text: String, projectInstructions: String?): Boolean {
if (projectInstructions.isNullOrBlank()) {
return false
}
return normalize(text) == normalize(projectInstructions)
}
private fun normalize(value: String): String {
return value.replace("\\s+".toRegex(), " ").trim()
}

View file

@ -0,0 +1,7 @@
package ee.carlrobert.codegpt.agent.history
data class AgentHistoryPage(
val items: List<AgentHistoryThreadSummary>,
val hasMore: Boolean,
val total: Int
)

View file

@ -0,0 +1,20 @@
package ee.carlrobert.codegpt.agent.history
import kotlinx.datetime.Instant
enum class ThreadStatus {
COMPLETED,
PARTIAL,
UNKNOWN
}
data class AgentHistoryThreadSummary(
val agentId: String,
val latest: CheckpointRef,
val latestCreatedAt: Instant,
val status: ThreadStatus,
val runCount: Int,
val title: String,
val preview: String,
val searchText: String = ""
)

View file

@ -0,0 +1,6 @@
package ee.carlrobert.codegpt.agent.history
data class CheckpointRef(
val agentId: String,
val checkpointId: String
)

View file

@ -11,7 +11,6 @@ import ai.koog.agents.core.dsl.extension.*
import ai.koog.agents.core.environment.ReceivedToolResult
import ai.koog.agents.core.environment.result
import ai.koog.agents.features.tokenizer.feature.tokenizer
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.prompt.dsl.Prompt
import ai.koog.prompt.executor.model.PromptExecutor
import ai.koog.prompt.message.Message
@ -29,6 +28,9 @@ import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import java.util.*
import ee.carlrobert.codegpt.conversations.message.Message as ChatMessage
@ -36,8 +38,6 @@ internal interface AgentRunStrategyProvider {
fun build(
project: Project,
executor: PromptExecutor,
projectInstructions: String?,
previousCheckpoint: AgentCheckpointData?,
pendingMessageQueue: ArrayDeque<MessageWithContext>,
historyCompressionConfig: HistoryCompressionConfig,
events: AgentEvents,
@ -56,8 +56,6 @@ internal class SingleRunStrategyProvider : AgentRunStrategyProvider {
override fun build(
project: Project,
executor: PromptExecutor,
projectInstructions: String?,
previousCheckpoint: AgentCheckpointData?,
pendingMessageQueue: ArrayDeque<MessageWithContext>,
historyCompressionConfig: HistoryCompressionConfig,
events: AgentEvents,
@ -67,29 +65,6 @@ internal class SingleRunStrategyProvider : AgentRunStrategyProvider {
): AIAgentGraphStrategy<MessageWithContext, String> = strategy("single_run") {
val nodeCallLLM by node<MessageWithContext, List<Message.Response>> { message ->
llm.writeSession {
if (previousCheckpoint == null) {
projectInstructions?.let {
appendPrompt {
user(it)
}
}
} else {
val filteredMessageHistory = previousCheckpoint.messageHistory
.filter { it !is Message.System }
.toMutableList()
.apply {
if (lastOrNull() is Message.Tool.Call) {
removeLast()
}
}
for (previousMessage in filteredMessageHistory) {
if (previousMessage !is Message.System) {
appendPrompt { message(previousMessage) }
}
}
}
if (message.tags.isNotEmpty()) {
val context = buildTagContext(project, message.tags)
if (context.isNotBlank()) {
@ -206,7 +181,24 @@ private suspend fun AIAgentLLMWriteSession.requestResponses(
val responses = if (stream) {
val streamFrames = mutableListOf<StreamFrame>()
requestLLMStreaming().collect { streamFrames.add(it) }
streamFrames.toMessageResponses()
streamFrames
.toMessageResponses()
.map {
if (it is Message.Tool.Call) {
try {
// validate json
Json.parseToJsonElement(it.content).jsonObject
it
} catch (_: SerializationException) {
// allows agent to retry the request
it.copy(parts = listOf(it.parts[0].copy(text = "{}")))
} catch (e: Exception) {
throw e
}
} else {
it
}
}
} else {
val preparedPrompt = config.missingToolsConversionStrategy.convertPrompt(prompt, tools)
executor.execute(preparedPrompt, model, tools)

View file

@ -71,7 +71,6 @@ abstract class BaseTool<Args : Any, Result : Any>(
BEFORE_TOOL_USE,
beforePayload,
name,
toolId,
sessionId
)
@ -111,7 +110,6 @@ abstract class BaseTool<Args : Any, Result : Any>(
HookEventType.AFTER_TOOL_USE,
afterPayload,
name,
toolId,
sessionId
)

View file

@ -249,8 +249,7 @@ class BashTool(
val deniedReason = hookManager.checkHooksForDenial(
HookEventType.BEFORE_BASH_EXECUTION,
prePayload,
"Bash",
toolId,
name,
sessionId
)
if (deniedReason != null) {
@ -291,8 +290,7 @@ class BashTool(
hookManager.executeHooksForEvent(
HookEventType.AFTER_BASH_EXECUTION,
postPayload,
"Bash",
toolId,
name,
sessionId
)
@ -309,8 +307,7 @@ class BashTool(
hookManager.executeHooksForEvent(
HookEventType.AFTER_BASH_EXECUTION,
errorPayload,
"Bash",
toolId,
name,
sessionId
)
Result(

View file

@ -10,7 +10,6 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFileManager
import ee.carlrobert.codegpt.agent.ToolRunContext
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.settings.hooks.HookEventType
import ee.carlrobert.codegpt.settings.hooks.HookManager
@ -280,7 +279,6 @@ class EditTool(
editLocations = editLocations
)
val toolId = sessionId?.let { id -> ToolRunContext.getToolId(id) }
val payload = mapOf(
"file_path" to args.filePath,
"replacements_made" to replacementsMade,
@ -295,7 +293,6 @@ class EditTool(
HookEventType.AFTER_FILE_EDIT,
payload,
"Edit",
toolId,
sessionId
)
if (deniedReason != null) {

View file

@ -8,7 +8,6 @@ import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFileManager
import ee.carlrobert.codegpt.agent.ToolRunContext
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.settings.ToolPermissionPolicy
import ee.carlrobert.codegpt.settings.hooks.HookEventType
@ -216,7 +215,6 @@ class ReadTool(
}
if (result is Result.Success) {
val toolId = sessionId?.let { id -> ToolRunContext.getToolId(id) }
val payload = mapOf(
"file_path" to args.filePath,
"content" to result.content,
@ -225,8 +223,7 @@ class ReadTool(
val deniedReason = hookManager.checkHooksForDenial(
HookEventType.BEFORE_READ_FILE,
payload,
"Read",
toolId,
name,
sessionId
)
if (deniedReason != null) {

View file

@ -113,7 +113,6 @@ class TaskTool(
HookEventType.SUBAGENT_START,
startPayload,
"Task",
ToolRunContext.getToolId(sessionId),
sessionId
)
}
@ -188,7 +187,10 @@ class TaskTool(
}
private fun configuredSubagent(subagentType: String): ConfiguredSubagent? {
return if (isBuiltInAgentType(subagentType)) null else findConfiguredSubagent(project, subagentType)
return if (isBuiltInAgentType(subagentType)) null else findConfiguredSubagent(
project,
subagentType
)
}
private suspend fun emitStopHook(args: Args, output: String, duration: Long) {
@ -201,8 +203,7 @@ class TaskTool(
"result" to output,
"duration" to duration
),
"Task",
ToolRunContext.getToolId(sessionId),
name,
sessionId
)
}
@ -301,11 +302,14 @@ class TaskTool(
details.contains("Unexpected response: no tool calls and no content", true) ->
"Subagent '$subagentType' received an invalid model response: no content " +
"and no tool calls were returned."
details.equals("Something went wrong", true) ||
details.equals("Something went wrong.", true) ->
"Subagent '$subagentType' received a generic provider error without details."
details.isNotBlank() ->
"Subagent '$subagentType' failed in the LLM client: $details"
else ->
"Subagent '$subagentType' failed in the LLM client without an error message."
}

View file

@ -18,7 +18,6 @@ class HookManager(
event: HookEventType,
payload: Map<String, Any?>,
toolName: String? = null,
toolId: String? = null,
sessionId: String? = null
): List<HookExecutionResult> {
val settings = project.service<ProxyAISettingsService>().getSettings()
@ -81,10 +80,9 @@ class HookManager(
event: HookEventType,
payload: Map<String, Any?>,
toolName: String? = null,
toolId: String? = null,
sessionId: String? = null
): String? {
val results = executeHooksForEvent(event, payload, toolName, toolId, sessionId)
val results = executeHooksForEvent(event, payload, toolName, sessionId)
results.forEach { result ->
when (result) {
is HookExecutionResult.Denied -> return result.reason

View file

@ -1,6 +1,7 @@
package ee.carlrobert.codegpt.toolwindow.agent
import com.intellij.openapi.vfs.VirtualFile
import ee.carlrobert.codegpt.agent.history.CheckpointRef
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.settings.service.ServiceType
@ -13,6 +14,8 @@ data class AgentSession(
val conversation: Conversation,
var displayName: String = "",
var serviceType: ServiceType? = null,
var runtimeAgentId: String? = null,
var resumeCheckpointRef: CheckpointRef? = null,
val referencedFiles: List<VirtualFile> = emptyList(),
val createdAt: Long = System.currentTimeMillis(),
var lastActiveAt: Long = System.currentTimeMillis()

View file

@ -5,9 +5,12 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindowManager
import ee.carlrobert.codegpt.agent.AgentService
import ee.carlrobert.codegpt.agent.history.AgentHistoryThreadSummary
import ee.carlrobert.codegpt.agent.history.CheckpointRef
import ee.carlrobert.codegpt.conversations.Conversation
import java.util.UUID
import java.util.*
@Service(Service.Level.PROJECT)
class AgentToolWindowContentManager(private val project: Project) : Disposable {
@ -43,6 +46,35 @@ class AgentToolWindowContentManager(private val project: Project) : Disposable {
return tabPanel
}
fun openCheckpointConversation(
thread: AgentHistoryThreadSummary,
conversation: Conversation
): AgentToolWindowTabPanel {
selectAgentTab()
val recoveredName = thread.title.ifBlank { "Recovered ${thread.agentId.take(8)}" }
val session = AgentSession(
sessionId = UUID.randomUUID().toString(),
conversation = conversation,
displayName = recoveredName,
runtimeAgentId = thread.agentId,
resumeCheckpointRef = thread.latest
)
val panel = createNewAgentTab(session, select = true)
return panel
}
private fun selectAgentTab() {
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("ProxyAI") ?: return
toolWindow.show()
val contentManager = toolWindow.contentManager
val agentContent = contentManager.contents.firstOrNull { it.tabName == "Agent" }
?: contentManager.getContent(0)
if (agentContent != null) {
contentManager.setSelectedContent(agentContent)
}
}
fun getActiveTabPanel(): AgentToolWindowTabPanel? {
return tabbedPane.tryFindActiveTabPanel().orElse(null)
}
@ -81,8 +113,19 @@ class AgentToolWindowContentManager(private val project: Project) : Disposable {
project.service<AgentService>().removeSession(sessionId)
}
@Synchronized
fun getSession(sessionId: String): AgentSession? = activeSessions[sessionId]
@Synchronized
fun setRuntimeAgentId(sessionId: String, agentId: String?) {
activeSessions[sessionId]?.runtimeAgentId = agentId
}
@Synchronized
fun setResumeCheckpointRef(sessionId: String, ref: CheckpointRef?) {
activeSessions[sessionId]?.resumeCheckpointRef = ref
}
companion object {
fun getInstance(project: Project): AgentToolWindowContentManager {
return project.service()

View file

@ -15,6 +15,7 @@ import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.agent.AgentService
import ee.carlrobert.codegpt.agent.AgentToolOutputNotifier
import ee.carlrobert.codegpt.agent.MessageWithContext
import ee.carlrobert.codegpt.agent.ToolSpecs
import ee.carlrobert.codegpt.agent.ToolRunContext
import ee.carlrobert.codegpt.agent.rollback.RollbackService
import ee.carlrobert.codegpt.conversations.Conversation
@ -26,6 +27,7 @@ import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentToolWindowLandingPanel
import ee.carlrobert.codegpt.toolwindow.agent.ui.RollbackPanel
import ee.carlrobert.codegpt.toolwindow.agent.ui.TodoListPanel
import ee.carlrobert.codegpt.toolwindow.agent.ui.ToolCallCard
import ee.carlrobert.codegpt.toolwindow.chat.MessageBuilder
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction
import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository
@ -42,6 +44,7 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
import ee.carlrobert.codegpt.util.EditorUtil
import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JComponent
@ -51,6 +54,11 @@ class AgentToolWindowTabPanel(
private val project: Project,
private val agentSession: AgentSession
) : BorderLayoutPanel(), Disposable {
private val replayJson = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}
private val scrollablePanel = ChatToolWindowScrollablePanel()
private val tagManager = TagManager()
@ -106,13 +114,13 @@ class AgentToolWindowTabPanel(
sessionIdProvider = { sessionId },
conversationIdProvider = { conversation.id }
)
private lateinit var rollbackPanel: RollbackPanel
private var rollbackPanel: RollbackPanel
private val todoListPanel = TodoListPanel()
private val projectMessageBusConnection = project.messageBus.connect()
private val appMessageBusConnection = ApplicationManager.getApplication().messageBus.connect()
private val rollbackService = RollbackService.getInstance(project)
private val agentEventHandler = AgentEventHandler(
private val eventHandler = AgentEventHandler(
project = project,
sessionId = sessionId,
agentApprovalManager = AgentApprovalManager(project),
@ -150,10 +158,12 @@ class AgentToolWindowTabPanel(
if (conversation.messages.isEmpty()) {
displayLandingView()
} else {
displayRecoveredConversation()
}
userInputPanel.setStopEnabled(false)
Disposer.register(this, agentEventHandler)
Disposer.register(this, eventHandler)
}
private fun setupMessageBusSubscriptions() {
@ -172,7 +182,7 @@ class AgentToolWindowTabPanel(
object : AgentToolOutputNotifier {
override fun toolOutput(toolId: String, text: String, isError: Boolean) {
val namespacedToolId = "${sessionId}:${toolId}"
agentEventHandler.handleToolOutput(namespacedToolId, text, isError)
eventHandler.handleToolOutput(namespacedToolId, text, isError)
}
}
)
@ -220,6 +230,7 @@ class AgentToolWindowTabPanel(
private fun handleSubmit(text: String) {
if (text.isBlank()) return
scrollablePanel.clearLandingViewIfVisible()
agentSession.serviceType =
ModelSelectionService.getInstance().getServiceForFeature(FeatureType.AGENT)
@ -233,7 +244,7 @@ class AgentToolWindowTabPanel(
agentService.submitMessage(
MessageWithContext(text, userInputPanel.getSelectedTags()),
agentEventHandler,
eventHandler,
sessionId
)
return
@ -271,8 +282,8 @@ class AgentToolWindowTabPanel(
messagePanel.add(responsePanel)
scrollablePanel.update()
agentEventHandler.resetForNewSubmission()
agentEventHandler.setCurrentResponseBody(responseBody)
eventHandler.resetForNewSubmission()
eventHandler.setCurrentResponseBody(responseBody)
loadingLabel.text = CodeGPTBundle.get("toolwindow.chat.loading")
loadingLabel.isVisible = true
@ -281,7 +292,7 @@ class AgentToolWindowTabPanel(
agentService.clearPendingMessages(sessionId)
userInputPanel.setStopEnabled(true)
agentService.submitMessage(message, agentEventHandler, sessionId)
agentService.submitMessage(message, eventHandler, sessionId)
}
private fun handleCancel() {
@ -308,6 +319,102 @@ class AgentToolWindowTabPanel(
scrollablePanel.displayLandingView(createLandingView())
}
private fun displayRecoveredConversation() {
scrollablePanel.clearAll()
conversation.messages.forEach { message ->
val prompt = message.prompt.orEmpty()
val wrapper = scrollablePanel.addMessage(message.id)
val userPanel = UserMessagePanel(project, message, this)
userPanel.addCopyAction { CopyAction.copyToClipboard(prompt) }
wrapper.add(userPanel)
val responseBody = ChatMessageResponseBody(
project,
false,
false,
false,
false,
false,
this
)
addRecoveredToolCards(responseBody, message)
responseBody.withResponse(message.response.orEmpty())
val responsePanel = ResponseMessagePanel().apply {
setResponseContent(responseBody)
}
wrapper.add(responsePanel)
}
scrollablePanel.update()
scrollablePanel.scrollToBottom()
}
private fun addRecoveredToolCards(responseBody: ChatMessageResponseBody, message: Message) {
val toolCalls = message.toolCalls ?: return
val toolCallResults = message.toolCallResults ?: emptyMap()
toolCalls.forEach { toolCall ->
val toolName = toolCall.function.name ?: return@forEach
if (toolName == "TodoWrite") {
return@forEach
}
val rawArgs = toolCall.function.arguments.orEmpty()
val args = parseRecoveredToolArgs(toolName, rawArgs)
val card = createRecoveredToolCard(toolName, args, rawArgs)
responseBody.addToolStatusPanel(card)
val rawResult = toolCallResults[toolCall.id] ?: return@forEach
val parsedResult = parseRecoveredToolResult(toolName, rawResult)
val success = inferRecoveredToolSuccess(parsedResult, rawResult)
card.complete(success, parsedResult ?: rawResult)
}
}
private fun createRecoveredToolCard(
toolName: String,
args: Any,
rawArgs: String
): ToolCallCard {
return try {
ToolCallCard(project, toolName, args)
} catch (_: Exception) {
val fallbackName = "Recovered $toolName"
val fallbackArgs = rawArgs.ifBlank { "(no arguments)" }
ToolCallCard(project, fallbackName, fallbackArgs)
}
}
private fun parseRecoveredToolArgs(toolName: String, rawArgs: String): Any {
val payload = rawArgs.trim()
if (payload.isBlank()) return ""
return ToolSpecs.decodeArgsOrNull(
json = replayJson,
toolName = toolName,
payload = payload
) ?: payload
}
private fun parseRecoveredToolResult(toolName: String, rawResult: String): Any? {
val payload = rawResult.trim()
if (payload.isBlank()) return null
return ToolSpecs.decodeResultOrNull(
json = replayJson,
toolName = toolName,
payload = payload
) ?: payload
}
private fun inferRecoveredToolSuccess(parsedResult: Any?, rawResult: String): Boolean {
if (parsedResult != null) {
return parsedResult::class.simpleName != "Error"
}
return !rawResult.contains("failed", ignoreCase = true) &&
!rawResult.contains("error", ignoreCase = true)
}
private fun createLandingView(): AgentToolWindowLandingPanel {
return AgentToolWindowLandingPanel(project)
}
@ -376,8 +483,8 @@ class AgentToolWindowTabPanel(
responsePanel.setResponseContent(responseBody)
messagePanel.add(responsePanel)
agentEventHandler.resetForNewSubmission()
agentEventHandler.setCurrentResponseBody(responseBody)
eventHandler.resetForNewSubmission()
eventHandler.setCurrentResponseBody(responseBody)
scrollablePanel.update()
}

View file

@ -0,0 +1,375 @@
package ee.carlrobert.codegpt.toolwindow.agent.history
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.SearchTextField
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.agent.history.AgentHistoryThreadSummary
import ee.carlrobert.codegpt.agent.history.CheckpointRef
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.swing.*
import javax.swing.event.DocumentEvent
class AgentHistoryListPanel(
private val defaultLimit: Int? = null
) : BorderLayoutPanel() {
companion object {
val TIMESTAMP_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, h:mm a")
private const val MIN_VISIBLE_ROWS = 2
}
var onOpen: ((AgentHistoryThreadSummary) -> Unit)? = null
var onLoadPage: ((query: String, offset: Int, limit: Int, onResult: (List<AgentHistoryThreadSummary>, Boolean, Int) -> Unit) -> Unit)? =
null
private val searchField = SearchTextField()
private val statusLabel = JBLabel().apply {
font = JBFont.small()
foreground = UIUtil.getContextHelpForeground()
border = JBUI.Borders.empty(0, 8, 4, 8)
}
private val listModel = DefaultListModel<AgentHistoryThreadSummary>()
private val historyList = JBList(listModel).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
cellRenderer = AgentHistoryCellRenderer()
fixedCellHeight = 54
visibleRowCount = defaultLimit ?: 5
border = null
}
private val listScrollPane = JBScrollPane(historyList).apply {
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
border = null
viewport.background = UIUtil.getListBackground()
}
private val emptyPanel = BorderLayoutPanel().apply {
border = JBUI.Borders.empty(12, 8)
addToCenter(
JBLabel("No conversations found", SwingConstants.LEFT).apply {
foreground = UIUtil.getContextHelpForeground()
}
)
}
private val pageSize = 50
private var hasMore = false
private var isLoading = false
private var resetRequested = false
private var currentQuery = ""
private var currentTotal = 0
private var requestId = 0
init {
setupUI()
setupListeners()
updateStatus()
}
fun reload() {
requestPage(reset = true)
}
private fun setupUI() {
searchField.textEditor.emptyText.text = "Search checkpoint conversations"
searchField.border = JBUI.Borders.empty(4, 0, 2, 0)
historyList.border = JBUI.Borders.emptyTop(4)
val topPanel = BorderLayoutPanel()
.addToTop(searchField)
.addToBottom(statusLabel)
addToTop(topPanel)
addToCenter(listScrollPane)
}
private fun setupListeners() {
searchField.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
val nextQuery = searchField.text.trim()
if (nextQuery == currentQuery && !resetRequested) {
return
}
currentQuery = nextQuery
requestPage(reset = true)
}
})
listScrollPane.verticalScrollBar.addAdjustmentListener {
if (!it.valueIsAdjusting && shouldLoadNextPage()) {
requestPage(reset = false)
}
}
historyList.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 1) {
openAt(e)
}
}
override fun mouseExited(e: MouseEvent) {
historyList.cursor = Cursor.getDefaultCursor()
}
})
historyList.addMouseMotionListener(object : MouseAdapter() {
override fun mouseMoved(e: MouseEvent) {
val hasItem = indexAt(e) >= 0
historyList.cursor = if (hasItem) {
Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
} else {
Cursor.getDefaultCursor()
}
}
})
historyList.inputMap.put(KeyStroke.getKeyStroke("ENTER"), "openThread")
historyList.actionMap.put("openThread", object : AbstractAction() {
override fun actionPerformed(e: java.awt.event.ActionEvent?) {
openSelected()
}
})
}
private fun requestPage(reset: Boolean) {
val loader = onLoadPage ?: return
if (isLoading) {
if (reset) {
resetRequested = true
}
return
}
if (!reset && !hasMore) {
return
}
val previousSelection = historyList.selectedValue?.latest
val querySnapshot = currentQuery
val offset = if (reset) 0 else listModel.size
val requestSnapshot = ++requestId
if (reset) {
listModel.clear()
hasMore = false
currentTotal = 0
updateListViewportSize(0)
updateCenterView(isEmpty = false)
}
isLoading = true
updateStatus()
loader(querySnapshot, offset, pageSize) { pageItems, pageHasMore, pageTotal ->
if (requestSnapshot != requestId) {
return@loader
}
if (querySnapshot != currentQuery) {
isLoading = false
if (resetRequested) {
resetRequested = false
}
requestPage(reset = true)
return@loader
}
if (reset) {
listModel.clear()
}
pageItems.forEach(listModel::addElement)
hasMore = pageHasMore
currentTotal = pageTotal
isLoading = false
restoreSelection(previousSelection)
updateListViewportSize(listModel.size)
updateCenterView(isEmpty = listModel.isEmpty)
updateStatus()
if (resetRequested) {
resetRequested = false
requestPage(reset = true)
return@loader
}
if (shouldLoadNextPage()) {
requestPage(reset = false)
}
}
}
private fun restoreSelection(selected: CheckpointRef?) {
if (listModel.isEmpty) {
return
}
if (selected != null) {
val idx = (0 until listModel.size()).firstOrNull {
val item = listModel.getElementAt(it)
item.latest.agentId == selected.agentId && item.latest.checkpointId == selected.checkpointId
}
if (idx != null) {
historyList.selectedIndex = idx
historyList.ensureIndexIsVisible(idx)
return
}
}
historyList.selectedIndex = 0
}
private fun shouldLoadNextPage(): Boolean {
if (!hasMore || isLoading) {
return false
}
val scrollbar = listScrollPane.verticalScrollBar
val threshold = historyList.fixedCellHeight.takeIf { it > 0 } ?: 54
return scrollbar.value + scrollbar.visibleAmount >= scrollbar.maximum - threshold
}
private fun updateListViewportSize(itemCount: Int) {
val maxRows = defaultLimit ?: 5
val visibleRows = itemCount.coerceIn(MIN_VISIBLE_ROWS, maxRows)
val rowHeight = historyList.fixedCellHeight.takeIf { it > 0 } ?: 54
val height = rowHeight * visibleRows
listScrollPane.minimumSize = Dimension(0, rowHeight * MIN_VISIBLE_ROWS)
listScrollPane.preferredSize = Dimension(0, height)
}
private fun updateStatus() {
val loaded = listModel.size
val queryActive = currentQuery.isNotBlank()
statusLabel.text = when {
isLoading && loaded == 0 -> "Loading..."
currentTotal == 0 && queryActive -> "No results"
currentTotal == 0 -> "No threads"
isLoading -> "Showing $loaded of $currentTotal..."
else -> "Showing $loaded of $currentTotal"
}
}
private fun updateCenterView(isEmpty: Boolean) {
val current = if (componentCount > 1) getComponent(1) else null
val expected = if (isEmpty) emptyPanel else listScrollPane
if (current === expected) return
if (current != null) remove(current)
addToCenter(expected)
revalidate()
repaint()
}
private fun openSelected() {
historyList.selectedValue?.let { onOpen?.invoke(it) }
}
private fun openAt(e: MouseEvent) {
val index = indexAt(e)
if (index < 0) {
return
}
historyList.selectedIndex = index
openSelected()
}
private fun indexAt(e: MouseEvent): Int {
val index = historyList.locationToIndex(e.point)
if (index < 0) {
return -1
}
val bounds = historyList.getCellBounds(index, index) ?: return -1
return if (bounds.contains(e.point)) index else -1
}
private class AgentHistoryCellRenderer : JPanel(BorderLayout()),
ListCellRenderer<AgentHistoryThreadSummary> {
private val titleLabel = JBLabel().apply {
font = JBFont.label().asBold()
}
private val previewLabel = JBLabel().apply {
font = JBFont.small()
}
private val metaLabel = JBLabel().apply {
font = JBFont.small()
}
init {
isOpaque = true
border = JBUI.Borders.empty(4, 8)
val header = JPanel(BorderLayout()).apply {
isOpaque = false
add(titleLabel, BorderLayout.CENTER)
add(metaLabel, BorderLayout.EAST)
}
val body = JPanel(BorderLayout()).apply {
isOpaque = false
add(previewLabel, BorderLayout.CENTER)
}
val content = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(header)
add(body)
}
add(content, BorderLayout.CENTER)
}
override fun getListCellRendererComponent(
list: JList<out AgentHistoryThreadSummary>,
value: AgentHistoryThreadSummary,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
titleLabel.text =
ellipsize(value.title.ifBlank { "Recovered ${value.agentId.take(8)}" }, 44)
previewLabel.text = ellipsize(
value.preview.ifBlank { "No assistant response in checkpoint" },
56
)
metaLabel.text = formatTimestamp(value.latestCreatedAt.toString())
background =
if (isSelected) UIUtil.getListSelectionBackground(true) else UIUtil.getListBackground()
titleLabel.foreground =
if (isSelected) UIUtil.getListSelectionForeground(true) else UIUtil.getLabelForeground()
previewLabel.foreground =
if (isSelected) UIUtil.getListSelectionForeground(true) else UIUtil.getContextHelpForeground()
metaLabel.foreground =
if (isSelected) UIUtil.getListSelectionForeground(true) else UIUtil.getContextHelpForeground()
return this
}
}
}
private fun formatTimestamp(raw: String): String {
return runCatching {
val instant = Instant.parse(raw)
instant.atZone(ZoneId.systemDefault()).format(AgentHistoryListPanel.TIMESTAMP_FORMAT)
}.getOrElse { raw }
}
private fun ellipsize(value: String, maxChars: Int): String {
val cleaned = value.replace("\\s+".toRegex(), " ").trim()
if (cleaned.length <= maxChars) return cleaned
return cleaned.take(maxChars - 1) + "…"
}

View file

@ -1,6 +1,9 @@
package ee.carlrobert.codegpt.toolwindow.agent.ui
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.options.ShowSettingsUtil
@ -13,11 +16,18 @@ import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.agent.ProxyAIAgent.loadProjectInstructions
import ee.carlrobert.codegpt.agent.history.AgentCheckpointConversationMapper
import ee.carlrobert.codegpt.agent.history.AgentCheckpointHistoryService
import ee.carlrobert.codegpt.agent.history.AgentHistoryThreadSummary
import ee.carlrobert.codegpt.settings.GeneralSettings
import ee.carlrobert.codegpt.settings.agents.SubagentsConfigurable
import ee.carlrobert.codegpt.tokens.TokenComputationService
import ee.carlrobert.codegpt.toolwindow.agent.AgentToolWindowContentManager
import ee.carlrobert.codegpt.toolwindow.agent.history.AgentHistoryListPanel
import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel
import ee.carlrobert.codegpt.ui.UIUtil.createTextPane
import kotlinx.coroutines.runBlocking
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Desktop
@ -37,6 +47,10 @@ class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessag
private val logger = thisLogger()
}
private val historyService = project.service<AgentCheckpointHistoryService>()
private val historyListPanel = AgentHistoryListPanel(defaultLimit = 5)
private var refreshHistory = true
private fun createLabel(text: String) = JBLabel(text)
private fun createLink(text: String, onClick: () -> Unit) = ActionLink(text) { onClick() }
@ -47,7 +61,12 @@ class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessag
}
init {
historyListPanel.onOpen = { thread -> openCheckpointThread(thread) }
historyListPanel.onLoadPage = { query, offset, limit, onResult ->
loadHistoryPage(query, offset, limit, onResult)
}
addContent(buildContent())
loadHistory()
}
private fun buildContent(): JPanel {
@ -59,11 +78,20 @@ class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessag
}
private fun centerPanel(): JPanel {
val panel = BorderLayoutPanel()
panel.addToTop(topSectionsPanel())
panel.addToCenter(previousChatsPanel())
return panel
}
private fun topSectionsPanel(): JPanel {
val panel = JPanel()
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
panel.alignmentX = LEFT_ALIGNMENT
panel.add(actionsListPanel())
panel.add(Box.createVerticalStrut(12))
panel.add(projectInfoPanel())
panel.add(Box.createVerticalStrut(12))
return panel
}
@ -210,10 +238,25 @@ class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessag
container.add(Box.createVerticalStrut(2))
container.add(bottomRow)
}
panel.add(container)
return panel
}
private fun previousChatsPanel(): JPanel {
val panel = BorderLayoutPanel().apply {
border = JBUI.Borders.empty()
}
panel.addToTop(JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
alignmentX = LEFT_ALIGNMENT
isOpaque = false
add(createLabel("Previous Chats"))
add(Box.createVerticalStrut(6))
})
panel.addToCenter(historyListPanel)
return panel
}
private fun findProxyAiFile() =
project.basePath?.let {
@ -240,10 +283,66 @@ class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessag
private fun refresh() {
removeAll()
addContent(buildContent())
loadHistory()
revalidate()
repaint()
}
private fun loadHistory() {
refreshHistory = true
historyListPanel.reload()
}
private fun loadHistoryPage(
query: String,
offset: Int,
limit: Int,
onResult: (List<AgentHistoryThreadSummary>, Boolean, Int) -> Unit
) {
val shouldRefresh = offset == 0 && refreshHistory
ApplicationManager.getApplication().executeOnPooledThread {
val page = runCatching {
runBlocking {
historyService.listThreadsPage(
query = query,
offset = offset,
limit = limit,
refresh = shouldRefresh
)
}
}
.onFailure { logger.warn("Failed to load checkpoint history", it) }
.getOrNull()
runInEdt {
if (page != null) {
refreshHistory = false
}
onResult(page?.items.orEmpty(), page?.hasMore == true, page?.total ?: 0)
}
}
}
private fun openCheckpointThread(thread: AgentHistoryThreadSummary) {
ApplicationManager.getApplication().executeOnPooledThread {
val conversation = runCatching {
val checkpoint = runBlocking { historyService.loadCheckpoint(thread.latest) }
?: return@executeOnPooledThread
AgentCheckpointConversationMapper.toConversation(
checkpoint = checkpoint,
projectInstructions = loadProjectInstructions(project.basePath)
)
}.onFailure {
logger.warn("Failed to open checkpoint thread ${thread.agentId}", it)
}.getOrNull() ?: return@executeOnPooledThread
ApplicationManager.getApplication().invokeLater {
project.service<AgentToolWindowContentManager>()
.openCheckpointConversation(thread, conversation)
}
}
}
private fun welcomeMessage(): String {
val name = GeneralSettings.getCurrentState().displayName
return """

View file

@ -30,6 +30,7 @@ import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.ui.EditorTextField
import com.intellij.ui.JBColor
import com.intellij.util.IncorrectOperationException
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.CodeGPTKeys.IS_PROMPT_TEXT_FIELD_DOCUMENT
@ -64,6 +65,8 @@ class PromptTextField(
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val lookupManager = PromptTextFieldLookupManager(project, onLookupAdded)
private val searchManager = SearchManager(project, tagManager, featureType)
@Volatile
private var isFieldDisposed = false
private var mouseClickListener: MouseAdapter? = null
private var mouseMotionListener: MouseMotionAdapter? = null
@ -85,6 +88,9 @@ class PromptTextField(
}
override fun onEditorAdded(editor: Editor) {
if (isFieldDisposed) {
return
}
IdeEventQueue.getInstance().addDispatcher(
PromptTextFieldEventDispatcher(dispatcherId, onBackSpace) { event ->
val shown = lookup?.let { it.isShown && !it.isLookupDisposed } == true
@ -279,6 +285,9 @@ class PromptTextField(
override fun createEditor(): EditorEx {
val editorEx = super.createEditor()
if (isFieldDisposed) {
return editorEx
}
editorEx.settings.isUseSoftWraps = true
editorEx.backgroundColor = service<EditorColorsManager>().globalScheme.defaultBackground
setupDocumentListener(editorEx)
@ -295,6 +304,7 @@ class PromptTextField(
}
override fun dispose() {
isFieldDisposed = true
showSuggestionsJob?.cancel()
lastSearchResults = null
clearPlaceholders()
@ -452,13 +462,24 @@ class PromptTextField(
}
private fun setupDocumentListener(editor: EditorEx) {
editor.document.addDocumentListener(object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
adjustHeight(editor)
onTextChanged(event.document.text)
handleDocumentChange(event)
if (isFieldDisposed) {
return
}
try {
editor.document.addDocumentListener(object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
adjustHeight(editor)
onTextChanged(event.document.text)
handleDocumentChange(event)
}
}, this)
} catch (e: IncorrectOperationException) {
if (!isFieldDisposed) {
throw e
}
}, this)
logger.debug("Skipping document listener registration for disposed PromptTextField", e)
}
}
private fun handleDocumentChange(event: DocumentEvent) {

View file

@ -11,12 +11,16 @@ import ee.carlrobert.codegpt.mcp.McpTool
import ee.carlrobert.codegpt.settings.prompts.PersonaDetails
import ee.carlrobert.codegpt.ui.DocumentationDetails
import git4idea.GitCommit
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import java.util.*
import javax.swing.Icon
@Serializable
sealed class TagDetails(
val name: String,
val icon: Icon? = null,
@Contextual
val id: UUID = UUID.randomUUID(),
val createdOn: Long = System.currentTimeMillis(),
val isRemovable: Boolean = true
@ -125,7 +129,7 @@ class EditorSelectionTagDetails(
data class DocumentationTagDetails(var documentationDetails: DocumentationDetails) :
TagDetails(documentationDetails.name, AllIcons.Toolwindows.Documentation) {
override fun getTooltipText(): String? = documentationDetails.url
override fun getTooltipText(): String = documentationDetails.url
}
data class PersonaTagDetails(var personaDetails: PersonaDetails) :
@ -135,7 +139,7 @@ data class PersonaTagDetails(var personaDetails: PersonaDetails) :
data class GitCommitTagDetails(var gitCommit: GitCommit) :
TagDetails(gitCommit.id.asString().take(6), AllIcons.Vcs.CommitNode) {
override fun getTooltipText(): String? = gitCommit.fullMessage
override fun getTooltipText(): String = gitCommit.fullMessage
}
class CurrentGitChangesTagDetails :

View file

@ -0,0 +1,324 @@
package ee.carlrobert.codegpt.agent
import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.core.agent.AIAgentService
import ai.koog.agents.core.agent.config.AIAgentConfig
import ai.koog.agents.core.tools.ToolRegistry
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import ai.koog.prompt.dsl.Prompt
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.model.PromptExecutor
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.message.Message
import ai.koog.prompt.message.RequestMetaInfo
import ai.koog.prompt.message.ResponseMetaInfo
import ai.koog.prompt.streaming.StreamFrame
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.agent.history.CheckpointRef
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.toolwindow.agent.AgentSession
import ee.carlrobert.codegpt.toolwindow.agent.AgentToolWindowContentManager
import ee.carlrobert.codegpt.ui.textarea.header.tag.McpTagDetails
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import kotlinx.serialization.json.JsonNull
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.groups.Tuple.tuple
import testsupport.IntegrationTest
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.Path
class AgentServiceIntegrationTest : IntegrationTest() {
private lateinit var agentService: AgentService
private lateinit var contentManager: AgentToolWindowContentManager
private lateinit var originalRuntimeFactory: AgentRuntimeFactory
private val createdSessions = mutableListOf<String>()
private val noopEvents = object : AgentEvents {
override fun onQueuedMessagesResolved() = Unit
}
private val runTimeoutMillis = 5_000L
override fun setUp() {
super.setUp()
agentService = project.service()
contentManager = project.service()
originalRuntimeFactory = agentService.runtimeFactory
}
override fun tearDown() {
agentService.runtimeFactory = originalRuntimeFactory
createdSessions.forEach { sessionId ->
runCatching { contentManager.removeSession(sessionId) }
}
createdSessions.clear()
super.tearDown()
}
fun testSubmitMessageReusesRuntimeAndUpdatesResumeCheckpoint() {
val sessionId = createSession("agent-runtime-reuse")
val factory = RecordingRuntimeFactory(agentModel())
agentService.runtimeFactory = factory
val firstMessage = MessageWithContext("First request")
val secondMessage = MessageWithContext("Second request")
submitAndAwait(sessionId, firstMessage)
val firstSnapshot = snapshot(sessionId)
submitAndAwait(sessionId, secondMessage)
val secondSnapshot = snapshot(sessionId)
val runtimeAgentId = firstSnapshot.runtimeAgentId
val managedService = factory.createdServices.single()
assertThat(runtimeAgentId).isNotBlank()
assertThat(factory.createdServices).hasSize(1)
assertThat(listOf(firstSnapshot, secondSnapshot))
.extracting("runtimeAgentId", "resumeCheckpointRef.agentId")
.containsExactly(
tuple(runtimeAgentId, runtimeAgentId),
tuple(runtimeAgentId, runtimeAgentId)
)
assertThat(firstSnapshot.resumeCheckpointRef?.checkpointId).isNotBlank()
assertThat(secondSnapshot.resumeCheckpointRef?.checkpointId).isNotBlank()
assertThat(secondSnapshot.resumeCheckpointRef?.checkpointId)
.isNotEqualTo(firstSnapshot.resumeCheckpointRef?.checkpointId)
assertThat(managedService.createdAgentIds).hasSize(2)
assertThat(managedService.createdAgentIds.last()).isEqualTo(runtimeAgentId)
}
fun testSubmitMessageRebuildsRuntimeWhenMcpSelectionChanges() {
val sessionId = createSession("agent-runtime-mcp")
val factory = RecordingRuntimeFactory(agentModel())
agentService.runtimeFactory = factory
val withoutMcp = MessageWithContext("Without MCP")
val mcpTag = McpTagDetails(serverId = "server-1", serverName = "Server 1")
val withMcp = MessageWithContext("With MCP", tags = listOf(mcpTag))
submitAndAwait(sessionId, withoutMcp)
submitAndAwait(sessionId, withMcp)
assertThat(factory.createdServices).hasSize(2)
assertThat(factory.createdServices)
.extracting("closeAllCalls")
.containsExactly(1, 0)
}
fun testGetCheckpointFallsBackToRuntimeAgentIdWhenResumeRefIsStale() {
val sessionId = createSession("agent-checkpoint-fallback")
val runtimeAgentId = "runtime-agent-${UUID.randomUUID()}"
val checkpointId = "checkpoint-${UUID.randomUUID()}"
val checkpointStorage = JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai"))
val staleRef = CheckpointRef(agentId = "missing-agent", checkpointId = "missing-checkpoint")
runBlocking {
checkpointStorage.saveCheckpoint(
runtimeAgentId,
AgentCheckpointData(
checkpointId = checkpointId,
createdAt = Clock.System.now(),
nodePath = "$runtimeAgentId/single_run/nodeExecuteTool",
lastInput = JsonNull,
messageHistory = listOf(
Message.User("hello", RequestMetaInfo.Empty),
Message.Assistant("world", ResponseMetaInfo.Empty),
),
version = 0,
)
)
}
contentManager.setRuntimeAgentId(sessionId, runtimeAgentId)
contentManager.setResumeCheckpointRef(sessionId, staleRef)
val checkpoint = runBlocking { agentService.getCheckpoint(sessionId) }
assertThat(checkpoint).isNotNull
assertThat(checkpoint!!.checkpointId).isEqualTo(checkpointId)
assertThat(contentManager.getSession(sessionId)!!.resumeCheckpointRef)
.isEqualTo(CheckpointRef(runtimeAgentId, checkpointId))
}
private fun submitAndAwait(
sessionId: String,
message: MessageWithContext
) {
agentService.submitMessage(message, noopEvents, sessionId)
awaitSessionToFinish(sessionId)
}
private fun awaitSessionToFinish(sessionId: String) {
val deadline = System.currentTimeMillis() + runTimeoutMillis
while (agentService.isSessionRunning(sessionId) && System.currentTimeMillis() < deadline) {
Thread.sleep(20)
}
assertThat(agentService.isSessionRunning(sessionId)).isFalse
}
private fun createSession(prefix: String): String {
val sessionId = "$prefix-${UUID.randomUUID()}"
contentManager.createNewAgentTab(
AgentSession(
sessionId = sessionId,
conversation = Conversation()
),
select = false
)
createdSessions.add(sessionId)
return sessionId
}
private fun snapshot(sessionId: String): SessionSnapshot {
val session = requireNotNull(contentManager.getSession(sessionId))
return SessionSnapshot(
runtimeAgentId = session.runtimeAgentId,
resumeCheckpointRef = session.resumeCheckpointRef
)
}
private fun agentModel(): LLModel {
return project.service<ModelSelectionService>().getAgentModel()
}
private data class SessionSnapshot(
val runtimeAgentId: String?,
val resumeCheckpointRef: CheckpointRef?
)
}
private class RecordingRuntimeFactory(
private val model: LLModel
) : AgentRuntimeFactory {
val createdServices = mutableListOf<FakeManagedAgentService>()
override fun create(
project: Project,
checkpointStorage: JVMFilePersistenceStorageProvider,
provider: ServiceType,
events: AgentEvents,
sessionId: String,
pendingMessages: ConcurrentHashMap<String, ArrayDeque<MessageWithContext>>
): AIAgentService<MessageWithContext, String, out AIAgent<MessageWithContext, String>> {
val service = FakeManagedAgentService(checkpointStorage, model)
createdServices.add(service)
return service
}
}
private class FakeManagedAgentService(
private val checkpointStorage: JVMFilePersistenceStorageProvider,
model: LLModel
) : AIAgentService<MessageWithContext, String, FakeManagedAgent> {
override val promptExecutor: PromptExecutor = NoopPromptExecutor
override val agentConfig: AIAgentConfig = AIAgentConfig(
prompt = prompt("fake-service") {},
model = model,
maxAgentIterations = 1
)
override val toolRegistry: ToolRegistry = ToolRegistry.EMPTY
private val agents = linkedMapOf<String, FakeManagedAgent>()
val createdAgentIds = mutableListOf<String>()
var closeAllCalls: Int = 0
override suspend fun createAgent(
id: String?,
additionalToolRegistry: ToolRegistry,
agentConfig: AIAgentConfig,
clock: Clock
): FakeManagedAgent {
val actualId = id ?: UUID.randomUUID().toString()
val agent = FakeManagedAgent(actualId, agentConfig, checkpointStorage)
agents[actualId] = agent
createdAgentIds.add(actualId)
return agent
}
override suspend fun removeAgent(agent: FakeManagedAgent): Boolean {
return removeAgentWithId(agent.id)
}
override suspend fun removeAgentWithId(id: String): Boolean {
return agents.remove(id) != null
}
override suspend fun agentById(id: String): FakeManagedAgent? = agents[id]
override suspend fun listAllAgents(): List<FakeManagedAgent> = agents.values.toList()
override suspend fun listActiveAgents(): List<FakeManagedAgent> = emptyList()
override suspend fun listInactiveAgents(): List<FakeManagedAgent> = agents.values.toList()
override suspend fun listFinishedAgents(): List<FakeManagedAgent> = agents.values.toList()
override suspend fun closeAll() {
closeAllCalls += 1
super.closeAll()
}
}
private class FakeManagedAgent(
override val id: String,
override val agentConfig: AIAgentConfig,
private val checkpointStorage: JVMFilePersistenceStorageProvider
) : AIAgent<MessageWithContext, String> {
private var state: AIAgent.Companion.State<String> = AIAgent.Companion.State.NotStarted()
override suspend fun getState(): AIAgent.Companion.State<String> = state
override suspend fun run(agentInput: MessageWithContext): String {
state = AIAgent.Companion.State.Starting()
val latest = checkpointStorage.getLatestCheckpoint(id)
val checkpoint = AgentCheckpointData(
checkpointId = UUID.randomUUID().toString(),
createdAt = Clock.System.now(),
nodePath = "$id/single_run/nodeExecuteTool",
lastInput = JsonNull,
messageHistory = listOf(
Message.User(agentInput.text, RequestMetaInfo.Empty),
Message.Assistant("ok", ResponseMetaInfo.Empty)
),
version = (latest?.version ?: -1) + 1
)
checkpointStorage.saveCheckpoint(id, checkpoint)
state = AIAgent.Companion.State.Finished("ok")
return "ok"
}
override suspend fun close() = Unit
}
private object NoopPromptExecutor : PromptExecutor {
override suspend fun execute(
prompt: Prompt,
model: LLModel,
tools: List<ai.koog.agents.core.tools.ToolDescriptor>
): List<Message.Response> {
throw UnsupportedOperationException("NoopPromptExecutor should not be used in tests")
}
override fun executeStreaming(
prompt: Prompt,
model: LLModel,
tools: List<ai.koog.agents.core.tools.ToolDescriptor>
): Flow<StreamFrame> {
throw UnsupportedOperationException("NoopPromptExecutor should not be used in tests")
}
override suspend fun moderate(
prompt: Prompt,
model: LLModel
): ai.koog.prompt.dsl.ModerationResult {
throw UnsupportedOperationException("NoopPromptExecutor should not be used in tests")
}
override fun close() = Unit
}

View file

@ -0,0 +1,171 @@
package ee.carlrobert.codegpt.agent.history
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.prompt.message.Message
import ai.koog.prompt.message.RequestMetaInfo
import ai.koog.prompt.message.ResponseMetaInfo
import ee.carlrobert.codegpt.conversations.Conversation
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.groups.Tuple.tuple
import kotlin.test.Test
class AgentCheckpointConversationMapperTest {
private val actualUserTurn = "Actual user prompt" to "Actual response"
@Test
fun `maps user and assistant turns into conversation messages`() {
val checkpoint = checkpoint(
listOf(
Message.System("System", RequestMetaInfo.Empty),
Message.User("First prompt", RequestMetaInfo.Empty),
Message.Assistant("First response", ResponseMetaInfo.Empty),
Message.User("Second prompt", RequestMetaInfo.Empty),
Message.Assistant("Second response", ResponseMetaInfo.Empty)
)
)
val conversation = AgentCheckpointConversationMapper.toConversation(checkpoint, null)
assertThat(conversation.messages)
.extracting("prompt", "response")
.containsExactly(
tuple("First prompt", "First response"),
tuple("Second prompt", "Second response")
)
}
@Test
fun `maps tool calls and results into structured message fields`() {
val checkpoint = checkpoint(
listOf(
Message.User("Run the command", RequestMetaInfo.Empty),
Message.Assistant("Working on it", ResponseMetaInfo.Empty),
Message.Tool.Call(
id = "tool-1",
tool = "Bash",
content = """{"command":"ls"}""",
metaInfo = ResponseMetaInfo.Empty
),
Message.Tool.Result(
id = "tool-1",
tool = "Bash",
content = "file1.txt",
metaInfo = RequestMetaInfo.Empty
)
)
)
val conversation = AgentCheckpointConversationMapper.toConversation(checkpoint, null)
val mapped = conversation.messages.single()
assertThat(mapped.response).isEqualTo("Working on it")
assertThat(mapped.toolCalls.orEmpty())
.extracting("function.name", "function.arguments")
.containsExactly(tuple("Bash", """{"command":"ls"}"""))
assertThat(mapped.toolCallResults).containsEntry("tool-1", "file1.txt")
}
@Test
fun `filters project instructions from user turns`() {
val projectInstructions = "Follow project instructions carefully."
val checkpoint = checkpoint(
listOf(
Message.User(projectInstructions, RequestMetaInfo.Empty),
Message.User("Actual user prompt", RequestMetaInfo.Empty),
Message.Assistant("Actual response", ResponseMetaInfo.Empty)
)
)
val conversation = AgentCheckpointConversationMapper.toConversation(
checkpoint = checkpoint,
projectInstructions = projectInstructions
)
assertSingleTurn(conversation, actualUserTurn)
}
@Test
fun `filters cacheable user messages from user turns`() {
val checkpoint = checkpoint(
listOf(
Message.User(
content = "Project-level instruction payload",
metaInfo = cacheableMetaInfo()
),
Message.User("Actual user prompt", RequestMetaInfo.Empty),
Message.Assistant("Actual response", ResponseMetaInfo.Empty)
)
)
val conversation = AgentCheckpointConversationMapper.toConversation(
checkpoint = checkpoint,
projectInstructions = null
)
assertSingleTurn(conversation, actualUserTurn)
}
@Test
fun `merges multiple tool results for the same tool call`() {
val checkpoint = checkpoint(
listOf(
Message.User("Run the command", RequestMetaInfo.Empty),
Message.Tool.Call(
id = "tool-1",
tool = "Bash",
content = """{"command":"ls"}""",
metaInfo = ResponseMetaInfo.Empty
),
Message.Tool.Result(
id = "tool-1",
tool = "Bash",
content = "file1.txt",
metaInfo = RequestMetaInfo.Empty
),
Message.Tool.Result(
id = "tool-1",
tool = "Bash",
content = "file2.txt",
metaInfo = RequestMetaInfo.Empty
)
)
)
val conversation = AgentCheckpointConversationMapper.toConversation(checkpoint, null)
assertThat(conversation.messages.single().toolCallResults)
.containsEntry("tool-1", "file1.txt\n\nfile2.txt")
}
private fun cacheableMetaInfo(): RequestMetaInfo {
return RequestMetaInfo(
timestamp = Clock.System.now(),
metadata = JsonObject(mapOf("cacheable" to JsonPrimitive(true)))
)
}
private fun assertSingleTurn(
conversation: Conversation,
expectedTurn: Pair<String, String>
) {
assertThat(conversation.messages)
.extracting("prompt", "response")
.containsExactly(tuple(expectedTurn.first, expectedTurn.second))
}
private fun checkpoint(history: List<Message>): AgentCheckpointData {
return AgentCheckpointData(
checkpointId = "checkpoint-1",
createdAt = Instant.parse("2026-02-04T00:00:00Z"),
nodePath = "agent/single_run/nodeExecuteTool",
lastInput = JsonNull,
messageHistory = history,
version = 0
)
}
}

View file

@ -0,0 +1,301 @@
package ee.carlrobert.codegpt.agent.history
import ai.koog.agents.snapshot.feature.AgentCheckpointData
import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import ai.koog.prompt.message.Message
import ai.koog.prompt.message.RequestMetaInfo
import ai.koog.prompt.message.ResponseMetaInfo
import com.intellij.openapi.components.service
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.groups.Tuple.tuple
import testsupport.IntegrationTest
import kotlin.io.path.Path
import java.util.UUID
class AgentCheckpointHistoryServiceTest : IntegrationTest() {
fun testListsThreadsAndPicksLatestNonTombstoneCheckpoint() {
runBlocking {
val storage = storage()
val agentAId = "agent-a-${UUID.randomUUID()}"
val agentBId = "agent-b-${UUID.randomUUID()}"
storage.saveCheckpoint(
agentAId,
checkpoint(
checkpointId = "a-1",
createdAt = Instant.parse("2026-02-04T00:00:00Z"),
nodePath = "$agentAId/single_run/__finish__",
history = listOf(
Message.User("Initial prompt", RequestMetaInfo.Empty),
Message.Assistant("Initial response", ResponseMetaInfo.Empty)
)
)
)
storage.saveCheckpoint(
agentAId,
checkpoint(
checkpointId = "a-2",
createdAt = Instant.parse("2026-02-04T00:05:00Z"),
nodePath = "$agentAId/single_run/nodeExecuteTool",
history = listOf(
Message.User("Latest prompt", RequestMetaInfo.Empty),
Message.Assistant("Latest response", ResponseMetaInfo.Empty)
)
)
)
storage.saveCheckpoint(
agentAId,
checkpoint(
checkpointId = "a-3-tombstone",
createdAt = Instant.parse("2026-02-04T00:06:00Z"),
nodePath = "$agentAId/single_run/__finish__",
history = emptyList(),
tombstone = true
)
)
storage.saveCheckpoint(
agentBId,
checkpoint(
checkpointId = "b-1",
createdAt = Instant.parse("2026-02-04T00:01:00Z"),
nodePath = "$agentBId/single_run/__start__",
history = listOf(Message.User("Thread B", RequestMetaInfo.Empty))
)
)
val scopedThreads = historyService()
.listThreads()
.filter { it.agentId == agentAId || it.agentId == agentBId }
assertThat(scopedThreads)
.extracting(
"agentId",
"latest.checkpointId",
"status",
"runCount",
"title",
"preview"
)
.containsExactlyInAnyOrder(
tuple(
agentAId,
"a-2",
ThreadStatus.PARTIAL,
2,
"Latest prompt",
"Latest response"
),
tuple(agentBId, "b-1", ThreadStatus.UNKNOWN, 1, "Thread B", "")
)
}
}
fun testLoadsCheckpointByCheckpointReference() {
runBlocking {
val storage = storage()
val agentId = "agent-z-${UUID.randomUUID()}"
val persisted = checkpoint(
checkpointId = "a-9",
createdAt = Instant.parse("2026-02-04T00:15:00Z"),
nodePath = "$agentId/single_run/__finish__",
history = listOf(Message.User("Prompt", RequestMetaInfo.Empty))
)
storage.saveCheckpoint(agentId, persisted)
val loaded = historyService().loadCheckpoint(CheckpointRef(agentId, "a-9"))
assertThat(loaded).isNotNull
assertThat(loaded!!.checkpointId).isEqualTo("a-9")
assertThat(loaded.messageHistory).hasSize(1)
}
}
fun testLoadResumeCheckpointFallsBackWhenLatestIsFinishNode() {
runBlocking {
val storage = storage()
val agentId = "agent-r-${UUID.randomUUID()}"
storage.saveCheckpoint(
agentId,
checkpoint(
checkpointId = "r-1",
createdAt = Instant.parse("2026-02-04T01:00:00Z"),
nodePath = "$agentId/single_run/nodeExecuteTool",
history = listOf(Message.User("First", RequestMetaInfo.Empty))
)
)
storage.saveCheckpoint(
agentId,
checkpoint(
checkpointId = "r-2",
createdAt = Instant.parse("2026-02-04T01:01:00Z"),
nodePath = "$agentId/single_run/__finish__",
history = listOf(Message.Assistant("Done", ResponseMetaInfo.Empty))
)
)
val resume = historyService().loadResumeCheckpoint(CheckpointRef(agentId, "r-2"))
assertThat(resume).isNotNull
assertThat(resume!!.checkpointId).isEqualTo("r-1")
}
}
fun testLoadLatestResumeCheckpointPrefersResumableNodeWhenLatestIsFinish() {
runBlocking {
val storage = storage()
val agentId = "agent-latest-resume-${UUID.randomUUID()}"
storage.saveCheckpoint(
agentId,
checkpoint(
checkpointId = "latest-resumable",
createdAt = Instant.parse("2026-02-04T01:00:00Z"),
nodePath = "$agentId/single_run/nodeExecuteTool",
history = listOf(Message.User("First", RequestMetaInfo.Empty))
)
)
storage.saveCheckpoint(
agentId,
checkpoint(
checkpointId = "latest-finish",
createdAt = Instant.parse("2026-02-04T01:01:00Z"),
nodePath = "$agentId/single_run/__finish__",
history = listOf(Message.Assistant("Done", ResponseMetaInfo.Empty))
)
)
val latestResume = historyService().loadLatestResumeCheckpoint(agentId)
assertThat(latestResume).isNotNull
assertThat(latestResume!!.checkpointId).isEqualTo("latest-resumable")
}
}
fun testPrefersLatestTodoWriteTitleForThreadTitle() {
runBlocking {
val storage = storage()
val agentId = "agent-todo-${UUID.randomUUID()}"
storage.saveCheckpoint(
agentId,
checkpoint(
checkpointId = "todo-1",
createdAt = Instant.parse("2026-02-04T01:02:00Z"),
nodePath = "$agentId/single_run/nodeExecuteTool",
history = listOf(
Message.User("Please debug the crash", RequestMetaInfo.Empty),
Message.Tool.Call(
id = "tool-1",
tool = "TodoWrite",
content = """{"title":"Fix crash flow","todos":[]}""",
metaInfo = ResponseMetaInfo.Empty
),
Message.User("Also handle retries", RequestMetaInfo.Empty)
)
)
)
val thread = historyService().listThreads().first { it.agentId == agentId }
assertThat(thread.title).isEqualTo("Fix crash flow")
}
}
fun testFallsBackToLatestUserMessageWhenNoTodoWriteTitleExists() {
runBlocking {
val storage = storage()
val agentId = "agent-user-${UUID.randomUUID()}"
storage.saveCheckpoint(
agentId,
checkpoint(
checkpointId = "user-1",
createdAt = Instant.parse("2026-02-04T01:03:00Z"),
nodePath = "$agentId/single_run/nodeExecuteTool",
history = listOf(
Message.User("First request", RequestMetaInfo.Empty),
Message.Tool.Call(
id = "tool-2",
tool = "TodoWrite",
content = """{"title":" ","todos":[]}""",
metaInfo = ResponseMetaInfo.Empty
),
Message.User("Latest request", RequestMetaInfo.Empty)
)
)
)
val thread = historyService().listThreads().first { it.agentId == agentId }
assertThat(thread.title).isEqualTo("Latest request")
}
}
fun testSkipsCacheableUserMessagesWhenResolvingThreadTitle() {
runBlocking {
val storage = storage()
val agentId = "agent-cacheable-${UUID.randomUUID()}"
storage.saveCheckpoint(
agentId,
checkpoint(
checkpointId = "cacheable-1",
createdAt = Instant.parse("2026-02-04T01:04:00Z"),
nodePath = "$agentId/single_run/nodeExecuteTool",
history = listOf(
Message.User(
"Project-level instruction payload",
RequestMetaInfo(
timestamp = Clock.System.now(),
metadata = JsonObject(mapOf("cacheable" to JsonPrimitive(true)))
)
),
Message.User("Real user request", RequestMetaInfo.Empty)
)
)
)
val thread = historyService().listThreads().first { it.agentId == agentId }
assertThat(thread.title).isEqualTo("Real user request")
}
}
private fun storage(): JVMFilePersistenceStorageProvider {
return JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai"))
}
private fun historyService(): AgentCheckpointHistoryService {
return project.service()
}
private fun checkpoint(
checkpointId: String,
createdAt: Instant,
nodePath: String,
history: List<Message>,
tombstone: Boolean = false
): AgentCheckpointData {
val properties = if (tombstone) {
mapOf("tombstone" to JsonPrimitive(true))
} else {
null
}
return AgentCheckpointData(
checkpointId = checkpointId,
createdAt = createdAt,
nodePath = nodePath,
lastInput = JsonNull,
messageHistory = history,
version = 0,
properties = properties
)
}
}