diff --git a/.gitignore b/.gitignore index 55eccf8..a008f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ templates/**/.claude/commands/speckit*.md # Scoped to worktrees/ rather than all of .claude/ so tracked agent config # elsewhere in the estate is not silently ignored. .claude/worktrees/ + +# Kotlin compiler daemon session lock files. +.kotlin/ diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/contract/OpenApiSpecExportTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/contract/OpenApiSpecExportTest.kt index 3d7caba..0116f6a 100644 --- a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/contract/OpenApiSpecExportTest.kt +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/contract/OpenApiSpecExportTest.kt @@ -6,7 +6,7 @@ import com.jorisjonkers.personalstack.agents.application.RepositoryInstallationS import com.jorisjonkers.personalstack.agents.application.RepositoryVerificationService import com.jorisjonkers.personalstack.agents.application.chat.ChatAnswerStreamService import com.jorisjonkers.personalstack.agents.application.maintenance.RunnerMaintenanceService -import com.jorisjonkers.personalstack.agents.application.query.ChatSessionQueryService +import com.jorisjonkers.personalstack.agents.application.query.ConversationQueryService import com.jorisjonkers.personalstack.agents.application.query.GetTurnHistoryQueryService import com.jorisjonkers.personalstack.agents.application.query.GetWorkspaceQueryService import com.jorisjonkers.personalstack.agents.application.query.ListWorkspacesQueryService @@ -34,6 +34,7 @@ import com.jorisjonkers.personalstack.agents.infrastructure.web.AgentSessionCont import com.jorisjonkers.personalstack.agents.infrastructure.web.AgentSetupController import com.jorisjonkers.personalstack.agents.infrastructure.web.AgentSetupExceptionHandler import com.jorisjonkers.personalstack.agents.infrastructure.web.ChatSessionController +import com.jorisjonkers.personalstack.agents.infrastructure.web.ConversationController import com.jorisjonkers.personalstack.agents.infrastructure.web.CredentialController import com.jorisjonkers.personalstack.agents.infrastructure.web.GitController import com.jorisjonkers.personalstack.agents.infrastructure.web.HealthController @@ -77,6 +78,7 @@ import java.nio.file.Paths AgentSetupController::class, AgentSessionController::class, ChatSessionController::class, + ConversationController::class, CredentialController::class, GitController::class, HealthController::class, @@ -112,6 +114,7 @@ import java.nio.file.Paths AgentSetupController::class, AgentSessionController::class, ChatSessionController::class, + ConversationController::class, CredentialController::class, GitController::class, HealthController::class, @@ -246,7 +249,7 @@ class OpenApiSpecExportTest @TestConfiguration(proxyBeanMethods = false) class QueryCollaborators { @Bean - fun chatSessionQueryService(): ChatSessionQueryService = mockk(relaxed = true) + fun conversationQueryService(): ConversationQueryService = mockk(relaxed = true) @Bean fun getTurnHistoryQueryService(): GetTurnHistoryQueryService = mockk(relaxed = true) diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/flow/AgentsApiContractIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/flow/AgentsApiContractIntegrationTest.kt index 27c72f1..e499d22 100644 --- a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/flow/AgentsApiContractIntegrationTest.kt +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/flow/AgentsApiContractIntegrationTest.kt @@ -261,6 +261,7 @@ class AgentsApiContractIntegrationTest mockMvc .perform( post("/api/v1/chat-sessions/$sessionId/messages") + .header("X-User-Id", userId) .contentType(MediaType.APPLICATION_JSON) .content( objectMapper.writeValueAsString( @@ -273,9 +274,85 @@ class AgentsApiContractIntegrationTest .andExpect(jsonPath("$.sessionId").value(sessionId)) mockMvc - .perform(get("/api/v1/chat-sessions/$sessionId")) + .perform(get("/api/v1/chat-sessions/$sessionId").header("X-User-Id", userId)) .andExpect(status().isOk) .andExpect(jsonPath("$.session.id").value(sessionId)) .andExpect(jsonPath("$.messages[0].body").value("hello world")) } + + @Test + fun conversationCreationResponseMatchesExpectedSchema() { + val userId = UUID.randomUUID().toString() + mockMvc + .perform( + post("/api/v1/conversations") + .header("X-User-Id", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("title" to "Demo chat"))), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.userId").value(userId)) + .andExpect(jsonPath("$.title").value("Demo chat")) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.kind").value("PLAIN")) + } + + @Test + fun conversationListResponseIsValidJSONArray() { + val userId = UUID.randomUUID().toString() + mockMvc.perform( + post("/api/v1/conversations") + .header("X-User-Id", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("title" to "List me"))), + ) + mockMvc + .perform(get("/api/v1/conversations").header("X-User-Id", userId)) + .andExpect(status().isOk) + .andExpect(jsonPath("$").isArray) + .andExpect(jsonPath("$[0].id").exists()) + .andExpect(jsonPath("$[0].status").exists()) + } + + @Test + fun appendingAConversationMessageReturnsTheTypedEnvelopeAndDetail() { + val userId = UUID.randomUUID().toString() + val createResult = + mockMvc + .perform( + post("/api/v1/conversations") + .header("X-User-Id", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("title" to "msg-test"))), + ).andExpect(status().isCreated) + .andReturn() + val conversationId = objectMapper.readTree(createResult.response.contentAsString)["id"].asText() + + mockMvc + .perform( + post("/api/v1/conversations/$conversationId/messages") + .header("X-User-Id", userId) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf("body" to "hello world", "role" to "USER"), + ), + ), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.body").value("hello world")) + .andExpect(jsonPath("$.role").value("USER")) + .andExpect(jsonPath("$.conversationId").value(conversationId)) + + mockMvc + .perform(get("/api/v1/conversations/$conversationId").header("X-User-Id", userId)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(conversationId)) + .andExpect(jsonPath("$.messages[0].body").value("hello world")) + + mockMvc + .perform(get("/api/v1/conversations/$conversationId/messages").header("X-User-Id", userId)) + .andExpect(status().isOk) + .andExpect(jsonPath("$[0].body").value("hello world")) + .andExpect(jsonPath("$[0].content").value("hello world")) + } } diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/flow/ConversationOwnershipIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/flow/ConversationOwnershipIntegrationTest.kt new file mode 100644 index 0000000..698e0da --- /dev/null +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/flow/ConversationOwnershipIntegrationTest.kt @@ -0,0 +1,115 @@ +package com.jorisjonkers.personalstack.agents.flow + +import com.fasterxml.jackson.databind.ObjectMapper +import com.jorisjonkers.personalstack.agents.IntegrationTestBase +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.WebApplicationContext +import java.util.UUID + +// Split out from AgentsApiContractIntegrationTest (which was already at +// detekt's 15-function ceiling): a second user must never read or append +// to the first user's Conversation, on the canonical path or the +// deprecated alias (#80). +class ConversationOwnershipIntegrationTest + @Autowired + constructor( + private val webApplicationContext: WebApplicationContext, + ) : IntegrationTestBase { + private lateinit var mockMvc: MockMvc + private val objectMapper = ObjectMapper() + + @BeforeEach + fun setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build() + } + + @Test + fun aSecondUserCannotReadOrAppendToTheFirstUsersConversation() { + val ownerId = UUID.randomUUID().toString() + val otherUserId = UUID.randomUUID().toString() + val conversationId = createConversation("/api/v1/conversations", ownerId) + + mockMvc + .perform(get("/api/v1/conversations/$conversationId").header("X-User-Id", otherUserId)) + .andExpect(status().isNotFound) + + mockMvc + .perform(get("/api/v1/conversations/$conversationId/messages").header("X-User-Id", otherUserId)) + .andExpect(status().isNotFound) + + mockMvc + .perform( + post("/api/v1/conversations/$conversationId/messages") + .header("X-User-Id", otherUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "intruder", "role" to "USER"))), + ).andExpect(status().isNotFound) + + mockMvc + .perform( + post("/api/v1/conversations/$conversationId/messages/stream") + .header("X-User-Id", otherUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "intruder", "role" to "USER"))), + ).andExpect(status().isNotFound) + + mockMvc + .perform(get("/api/v1/conversations/$conversationId").header("X-User-Id", ownerId)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.messages").isEmpty) + + mockMvc + .perform(get("/api/v1/conversations/$conversationId/messages").header("X-User-Id", ownerId)) + .andExpect(status().isOk) + .andExpect(jsonPath("$").isEmpty) + } + + @Test + fun aSecondUserCannotReadOrAppendToTheFirstUsersChatSession() { + val ownerId = UUID.randomUUID().toString() + val otherUserId = UUID.randomUUID().toString() + val sessionId = createConversation("/api/v1/chat-sessions", ownerId) + + mockMvc + .perform(get("/api/v1/chat-sessions/$sessionId").header("X-User-Id", otherUserId)) + .andExpect(status().isNotFound) + + mockMvc + .perform( + post("/api/v1/chat-sessions/$sessionId/messages") + .header("X-User-Id", otherUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "intruder", "role" to "USER"))), + ).andExpect(status().isNotFound) + + mockMvc + .perform(get("/api/v1/chat-sessions/$sessionId").header("X-User-Id", ownerId)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.messages").isEmpty) + } + + private fun createConversation( + basePath: String, + ownerId: String, + ): String { + val createResult = + mockMvc + .perform( + post(basePath) + .header("X-User-Id", ownerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("title" to "private"))), + ).andExpect(status().isCreated) + .andReturn() + return objectMapper.readTree(createResult.response.contentAsString)["id"].asText() + } + } diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqChatMessageRepositoryIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqChatMessageRepositoryIntegrationTest.kt deleted file mode 100644 index 6eee1cd..0000000 --- a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqChatMessageRepositoryIntegrationTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.jorisjonkers.personalstack.agents.persistence - -import com.jorisjonkers.personalstack.agents.IntegrationTestBase -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessage -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.agents.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -class JooqChatMessageRepositoryIntegrationTest - @Autowired - constructor( - private val messages: ChatMessageRepository, - private val sessions: ChatSessionRepository, - ) : IntegrationTestBase { - private fun newSession(): ChatSession = - ChatSession( - id = ChatSessionId.random(), - userId = UUID.randomUUID(), - title = "session", - status = ChatSessionStatus.ACTIVE, - kind = ChatSessionKind.PLAIN, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ).also(sessions::save) - - private fun newMessage( - sessionId: ChatSessionId, - body: String = "hello", - createdAt: Instant = Instant.now(), - ) = ChatMessage( - id = ChatMessageId.random(), - sessionId = sessionId, - role = ChatMessageRole.USER, - body = body, - createdAt = createdAt, - ) - - @Test - fun saveAndFindByIdRoundTrip() { - val s = newSession() - val m = newMessage(s.id) - messages.save(m) - val loaded = (messages.findById(m.id)).required() - assertThat(loaded.body).isEqualTo("hello") - } - - @Test - fun findallbysessionidorderedbytimeReturnsMessagesChronologically() { - val s = newSession() - val first = newMessage(s.id, body = "first", createdAt = Instant.parse("2025-01-01T00:00:00Z")) - val second = newMessage(s.id, body = "second", createdAt = Instant.parse("2025-01-01T00:01:00Z")) - val third = newMessage(s.id, body = "third", createdAt = Instant.parse("2025-01-01T00:02:00Z")) - // Save out of order on purpose. - messages.save(third) - messages.save(first) - messages.save(second) - - val ordered = messages.findAllBySessionIdOrderedByTime(s.id) - assertThat(ordered.map { it.body }).containsExactly("first", "second", "third") - } - - @Test - fun cascadingDeleteRemovesMessagesWhenTheSessionIsRemoved() { - val s = newSession() - messages.save(newMessage(s.id)) - messages.save(newMessage(s.id)) - sessions.delete(s.id) - assertThat(messages.findAllBySessionIdOrderedByTime(s.id)).isEmpty() - } - - @Test - fun deleteallbysessionidRemovesTheRows() { - val s = newSession() - messages.save(newMessage(s.id)) - messages.save(newMessage(s.id)) - messages.deleteAllBySessionId(s.id) - assertThat(messages.findAllBySessionIdOrderedByTime(s.id)).isEmpty() - } - } diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqChatSessionRepositoryIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqChatSessionRepositoryIntegrationTest.kt deleted file mode 100644 index 8ecd8be..0000000 --- a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqChatSessionRepositoryIntegrationTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.jorisjonkers.personalstack.agents.persistence - -import com.jorisjonkers.personalstack.agents.IntegrationTestBase -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -class JooqChatSessionRepositoryIntegrationTest - @Autowired - constructor( - private val sessions: ChatSessionRepository, - ) : IntegrationTestBase { - private fun newSession( - userId: UUID = UUID.randomUUID(), - title: String? = "x", - kind: ChatSessionKind = ChatSessionKind.PLAIN, - ) = ChatSession( - id = ChatSessionId.random(), - userId = userId, - title = title, - status = ChatSessionStatus.ACTIVE, - kind = kind, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun saveAndFindByIdRoundTripTheKind() { - val s = newSession(kind = ChatSessionKind.KNOWLEDGE) - sessions.save(s) - assertThat(sessions.findById(s.id).required().kind).isEqualTo(ChatSessionKind.KNOWLEDGE) - } - - @Test - fun saveAndFindByIdRoundTripWithNullTitle() { - val s = newSession(title = null) - sessions.save(s) - val loaded = (sessions.findById(s.id)).required() - assertThat(loaded.title).isNull() - assertThat(loaded.status).isEqualTo(ChatSessionStatus.ACTIVE) - } - - @Test - fun findallbyuseridReturnsOnlyTheUserSSessions() { - val userA = UUID.randomUUID() - val userB = UUID.randomUUID() - sessions.save(newSession(userId = userA, title = "A1")) - sessions.save(newSession(userId = userA, title = "A2")) - sessions.save(newSession(userId = userB, title = "B")) - - val forA = sessions.findAllByUserId(userA) - assertThat(forA).hasSize(2) - assertThat(forA.map { it.title }).containsExactlyInAnyOrder("A1", "A2") - } - - @Test - fun saveUpdatesTheRowOnConflict() { - val s = newSession() - sessions.save(s) - sessions.save(s.copy(status = ChatSessionStatus.ARCHIVED, updatedAt = Instant.now())) - - val loaded = sessions.findById(s.id).required() - assertThat(loaded.status).isEqualTo(ChatSessionStatus.ARCHIVED) - } - - @Test - fun deleteRemovesTheRow() { - val s = newSession() - sessions.save(s) - sessions.delete(s.id) - assertThat(sessions.findById(s.id)).isNull() - } - } diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqConversationMessageRepositoryIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqConversationMessageRepositoryIntegrationTest.kt new file mode 100644 index 0000000..1aaa864 --- /dev/null +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqConversationMessageRepositoryIntegrationTest.kt @@ -0,0 +1,89 @@ +package com.jorisjonkers.personalstack.agents.persistence + +import com.jorisjonkers.personalstack.agents.IntegrationTestBase +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationMessageRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.Instant +import java.util.UUID + +class JooqConversationMessageRepositoryIntegrationTest + @Autowired + constructor( + private val messages: ConversationMessageRepository, + private val conversations: ConversationRepository, + ) : IntegrationTestBase { + private fun newConversation(): Conversation = + Conversation( + id = ConversationId.random(), + userId = UUID.randomUUID(), + title = "conversation", + status = ConversationStatus.ACTIVE, + kind = ConversationKind.PLAIN, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ).also(conversations::save) + + private fun newMessage( + conversationId: ConversationId, + body: String = "hello", + createdAt: Instant = Instant.now(), + ) = ConversationMessage( + id = ConversationMessageId.random(), + conversationId = conversationId, + role = ConversationMessageRole.USER, + body = body, + createdAt = createdAt, + ) + + @Test + fun saveAndFindByIdRoundTrip() { + val c = newConversation() + val m = newMessage(c.id) + messages.save(m) + val loaded = (messages.findById(m.id)).required() + assertThat(loaded.body).isEqualTo("hello") + } + + @Test + fun findallbyconversationidorderedbytimeReturnsMessagesChronologically() { + val c = newConversation() + val first = newMessage(c.id, body = "first", createdAt = Instant.parse("2025-01-01T00:00:00Z")) + val second = newMessage(c.id, body = "second", createdAt = Instant.parse("2025-01-01T00:01:00Z")) + val third = newMessage(c.id, body = "third", createdAt = Instant.parse("2025-01-01T00:02:00Z")) + // Save out of order on purpose. + messages.save(third) + messages.save(first) + messages.save(second) + + val ordered = messages.findAllByConversationIdOrderedByTime(c.id) + assertThat(ordered.map { it.body }).containsExactly("first", "second", "third") + } + + @Test + fun cascadingDeleteRemovesMessagesWhenTheConversationIsRemoved() { + val c = newConversation() + messages.save(newMessage(c.id)) + messages.save(newMessage(c.id)) + conversations.delete(c.id) + assertThat(messages.findAllByConversationIdOrderedByTime(c.id)).isEmpty() + } + + @Test + fun deleteallbyconversationidRemovesTheRows() { + val c = newConversation() + messages.save(newMessage(c.id)) + messages.save(newMessage(c.id)) + messages.deleteAllByConversationId(c.id) + assertThat(messages.findAllByConversationIdOrderedByTime(c.id)).isEmpty() + } + } diff --git a/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqConversationRepositoryIntegrationTest.kt b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqConversationRepositoryIntegrationTest.kt new file mode 100644 index 0000000..b6ba510 --- /dev/null +++ b/api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agents/persistence/JooqConversationRepositoryIntegrationTest.kt @@ -0,0 +1,80 @@ +package com.jorisjonkers.personalstack.agents.persistence + +import com.jorisjonkers.personalstack.agents.IntegrationTestBase +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.Instant +import java.util.UUID + +class JooqConversationRepositoryIntegrationTest + @Autowired + constructor( + private val conversations: ConversationRepository, + ) : IntegrationTestBase { + private fun newConversation( + userId: UUID = UUID.randomUUID(), + title: String? = "x", + kind: ConversationKind = ConversationKind.PLAIN, + ) = Conversation( + id = ConversationId.random(), + userId = userId, + title = title, + status = ConversationStatus.ACTIVE, + kind = kind, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + + @Test + fun saveAndFindByIdRoundTripTheKind() { + val c = newConversation(kind = ConversationKind.KNOWLEDGE) + conversations.save(c) + assertThat(conversations.findById(c.id).required().kind).isEqualTo(ConversationKind.KNOWLEDGE) + } + + @Test + fun saveAndFindByIdRoundTripWithNullTitle() { + val c = newConversation(title = null) + conversations.save(c) + val loaded = (conversations.findById(c.id)).required() + assertThat(loaded.title).isNull() + assertThat(loaded.status).isEqualTo(ConversationStatus.ACTIVE) + } + + @Test + fun findallbyuseridReturnsOnlyTheUserSConversations() { + val userA = UUID.randomUUID() + val userB = UUID.randomUUID() + conversations.save(newConversation(userId = userA, title = "A1")) + conversations.save(newConversation(userId = userA, title = "A2")) + conversations.save(newConversation(userId = userB, title = "B")) + + val forA = conversations.findAllByUserId(userA) + assertThat(forA).hasSize(2) + assertThat(forA.map { it.title }).containsExactlyInAnyOrder("A1", "A2") + } + + @Test + fun saveUpdatesTheRowOnConflict() { + val c = newConversation() + conversations.save(c) + conversations.save(c.copy(status = ConversationStatus.ARCHIVED, updatedAt = Instant.now())) + + val loaded = conversations.findById(c.id).required() + assertThat(loaded.status).isEqualTo(ConversationStatus.ARCHIVED) + } + + @Test + fun deleteRemovesTheRow() { + val c = newConversation() + conversations.save(c) + conversations.delete(c.id) + assertThat(conversations.findById(c.id)).isNull() + } + } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/chat/ChatAnswerStreamService.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/chat/ChatAnswerStreamService.kt index d8f21c5..a2d5255 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/chat/ChatAnswerStreamService.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/chat/ChatAnswerStreamService.kt @@ -1,12 +1,12 @@ package com.jorisjonkers.personalstack.agents.application.chat -import com.jorisjonkers.personalstack.agents.application.command.AppendChatMessageCommand -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId +import com.jorisjonkers.personalstack.agents.application.command.AppendConversationMessageCommand +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole import com.jorisjonkers.personalstack.agents.domain.port.ChatGenerationPort -import com.jorisjonkers.personalstack.agents.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationMessageRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository import com.jorisjonkers.personalstack.common.command.CommandBus import com.jorisjonkers.personalstack.common.exception.NotFoundException import org.springframework.beans.factory.annotation.Qualifier @@ -17,52 +17,52 @@ import java.util.concurrent.Executor @Service class ChatAnswerStreamService( - private val sessions: ChatSessionRepository, - private val messages: ChatMessageRepository, + private val conversations: ConversationRepository, + private val messages: ConversationMessageRepository, private val commandBus: CommandBus, private val generation: ChatGenerationPort, @param:Qualifier("chatStreamExecutor") private val executor: Executor, ) { fun stream( - sessionId: ChatSessionId, + conversationId: ConversationId, userBody: String, ): SseEmitter { require(userBody.isNotBlank()) { "chat message body must not be blank" } - sessions.findById(sessionId) - ?: throw NotFoundException("ChatSession", sessionId.value.toString()) + conversations.findById(conversationId) + ?: throw NotFoundException("Conversation", conversationId.value.toString()) val emitter = SseEmitter(TIMEOUT_MILLIS) executor.execute { - generateAnswer(sessionId, userBody, emitter) + generateAnswer(conversationId, userBody, emitter) } return emitter } private fun generateAnswer( - sessionId: ChatSessionId, + conversationId: ConversationId, userBody: String, emitter: SseEmitter, ) { runCatching { - val prompt = buildPrompt(sessionId, userBody) + val prompt = buildPrompt(conversationId, userBody) val full = generation.generate(prompt) { piece -> sendEvent(emitter, "chunk", mapOf("text" to piece)) } check(full.isNotBlank()) { "no answer produced" } - persistAgentMessage(sessionId, full, emitter) + persistAgentMessage(conversationId, full, emitter) }.onFailure { sendTerminalError(emitter, it) } } private fun buildPrompt( - sessionId: ChatSessionId, + conversationId: ConversationId, userBody: String, ): String { val history = messages - .findAllBySessionIdOrderedByTime(sessionId) + .findAllByConversationIdOrderedByTime(conversationId) .takeLast(MAX_HISTORY_MESSAGES) if (history.isEmpty()) return userBody @@ -70,7 +70,7 @@ class ChatAnswerStreamService( // streaming. Only append it when history does not already end with it, so // the prompt is identical regardless of persist-then-stream ordering. val currentTurnAlreadyPersisted = - history.last().let { it.role == ChatMessageRole.USER && it.body == userBody } + history.last().let { it.role == ConversationMessageRole.USER && it.body == userBody } return buildString { for (message in history) { append(labelFor(message.role)) @@ -85,24 +85,24 @@ class ChatAnswerStreamService( }.trimEnd('\n') } - private fun labelFor(role: ChatMessageRole): String = + private fun labelFor(role: ConversationMessageRole): String = when (role) { - ChatMessageRole.USER -> "User" - ChatMessageRole.ASSISTANT -> "Assistant" - ChatMessageRole.SYSTEM -> "System" + ConversationMessageRole.USER -> "User" + ConversationMessageRole.ASSISTANT -> "Assistant" + ConversationMessageRole.SYSTEM -> "System" } private fun persistAgentMessage( - sessionId: ChatSessionId, + conversationId: ConversationId, full: String, emitter: SseEmitter, ) { - val messageId = ChatMessageId.random() + val messageId = ConversationMessageId.random() commandBus.dispatch( - AppendChatMessageCommand( + AppendConversationMessageCommand( messageId = messageId, - sessionId = sessionId, - role = ChatMessageRole.ASSISTANT, + conversationId = conversationId, + role = ConversationMessageRole.ASSISTANT, body = full, ), ) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommand.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommand.kt deleted file mode 100644 index 9c53494..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommand.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.agents.application.command - -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.common.command.Command - -data class AppendChatMessageCommand( - val messageId: ChatMessageId, - val sessionId: ChatSessionId, - val role: ChatMessageRole, - val body: String, -) : Command diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommandHandler.kt deleted file mode 100644 index 94cbbe3..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommandHandler.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.jorisjonkers.personalstack.agents.application.command - -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessage -import com.jorisjonkers.personalstack.agents.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -@Component -class AppendChatMessageCommandHandler( - private val sessions: ChatSessionRepository, - private val messages: ChatMessageRepository, -) : CommandHandler { - @Transactional - override fun handle(command: AppendChatMessageCommand) { - require(command.body.isNotBlank()) { "chat message body must not be blank" } - val session = - sessions.findById(command.sessionId) - ?: throw NotFoundException("ChatSession", command.sessionId.value.toString()) - val now = Instant.now() - messages.save( - ChatMessage( - id = command.messageId, - sessionId = session.id, - role = command.role, - body = command.body, - createdAt = now, - ), - ) - sessions.save(session.copy(updatedAt = now)) - } -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommand.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommand.kt new file mode 100644 index 0000000..12ad56b --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommand.kt @@ -0,0 +1,13 @@ +package com.jorisjonkers.personalstack.agents.application.command + +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole +import com.jorisjonkers.personalstack.common.command.Command + +data class AppendConversationMessageCommand( + val messageId: ConversationMessageId, + val conversationId: ConversationId, + val role: ConversationMessageRole, + val body: String, +) : Command diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommandHandler.kt new file mode 100644 index 0000000..9c3276a --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommandHandler.kt @@ -0,0 +1,35 @@ +package com.jorisjonkers.personalstack.agents.application.command + +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.port.ConversationMessageRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.NotFoundException +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.Instant + +@Component +class AppendConversationMessageCommandHandler( + private val conversations: ConversationRepository, + private val messages: ConversationMessageRepository, +) : CommandHandler { + @Transactional + override fun handle(command: AppendConversationMessageCommand) { + require(command.body.isNotBlank()) { "conversation message body must not be blank" } + val conversation = + conversations.findById(command.conversationId) + ?: throw NotFoundException("Conversation", command.conversationId.value.toString()) + val now = Instant.now() + messages.save( + ConversationMessage( + id = command.messageId, + conversationId = conversation.id, + role = command.role, + body = command.body, + createdAt = now, + ), + ) + conversations.save(conversation.copy(updatedAt = now)) + } +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommandHandler.kt deleted file mode 100644 index 6ebb93a..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommandHandler.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.jorisjonkers.personalstack.agents.application.command - -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import com.jorisjonkers.personalstack.common.exception.DomainException -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -@Component -class ArchiveChatSessionCommandHandler( - private val sessions: ChatSessionRepository, -) : CommandHandler { - @Transactional - override fun handle(command: ArchiveChatSessionCommand) { - val session = - sessions.findById(command.sessionId) - ?: throw NotFoundException("ChatSession", command.sessionId.value.toString()) - - if (session.userId != command.userId) { - throw DomainException( - "User ${command.userId} does not own chat session ${command.sessionId.value}", - "FORBIDDEN", - ) - } - sessions.save( - session.copy( - status = ChatSessionStatus.ARCHIVED, - updatedAt = Instant.now(), - ), - ) - } -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommand.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommand.kt similarity index 54% rename from api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommand.kt rename to api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommand.kt index b29e91d..38f2caa 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommand.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommand.kt @@ -1,10 +1,10 @@ package com.jorisjonkers.personalstack.agents.application.command -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId import com.jorisjonkers.personalstack.common.command.Command import java.util.UUID -data class ArchiveChatSessionCommand( - val sessionId: ChatSessionId, +data class ArchiveConversationCommand( + val conversationId: ConversationId, val userId: UUID, ) : Command diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommandHandler.kt new file mode 100644 index 0000000..80fba7b --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommandHandler.kt @@ -0,0 +1,35 @@ +package com.jorisjonkers.personalstack.agents.application.command + +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.DomainException +import com.jorisjonkers.personalstack.common.exception.NotFoundException +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.Instant + +@Component +class ArchiveConversationCommandHandler( + private val conversations: ConversationRepository, +) : CommandHandler { + @Transactional + override fun handle(command: ArchiveConversationCommand) { + val conversation = + conversations.findById(command.conversationId) + ?: throw NotFoundException("Conversation", command.conversationId.value.toString()) + + if (conversation.userId != command.userId) { + throw DomainException( + "User ${command.userId} does not own conversation ${command.conversationId.value}", + "FORBIDDEN", + ) + } + conversations.save( + conversation.copy( + status = ConversationStatus.ARCHIVED, + updatedAt = Instant.now(), + ), + ) + } +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommand.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommand.kt index 4d1e889..ceb4cf8 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommand.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommand.kt @@ -21,8 +21,8 @@ import com.jorisjonkers.personalstack.common.command.Command * supplied if the operator wants a free-text URL for future * use, but the orchestrator does not act on it. * - * 3. `CHAT`: not handled here — chat lives in [ChatSession] and - * is created via [StartChatSessionCommand]. A `kind = CHAT` + * 3. `CHAT`: not handled here — chat lives in [Conversation] and + * is created via [StartConversationCommand]. A `kind = CHAT` * workspace creation request is rejected. * * [projectId] is optional context: setting it groups the workspace diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommandHandler.kt index 340616e..d8409d2 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommandHandler.kt @@ -55,7 +55,7 @@ class CreateWorkspaceCommandHandler( override fun handle(command: CreateWorkspaceCommand) { require(command.kind != WorkspaceKind.CHAT) { - "CHAT workspaces are not persisted — use StartChatSessionCommand instead" + "CHAT workspaces are not persisted — use StartConversationCommand instead" } warnDeprecatedGithubLink(command) val resolved = resolveRepo(command) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommand.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommand.kt deleted file mode 100644 index 8a0b2e8..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommand.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.agents.application.command - -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.common.command.Command -import java.util.UUID - -data class StartChatSessionCommand( - val sessionId: ChatSessionId, - val userId: UUID, - val title: String? = null, - val kind: ChatSessionKind = ChatSessionKind.PLAIN, -) : Command diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommand.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommand.kt new file mode 100644 index 0000000..4e95429 --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommand.kt @@ -0,0 +1,13 @@ +package com.jorisjonkers.personalstack.agents.application.command + +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.common.command.Command +import java.util.UUID + +data class StartConversationCommand( + val conversationId: ConversationId, + val userId: UUID, + val title: String? = null, + val kind: ConversationKind = ConversationKind.PLAIN, +) : Command diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommandHandler.kt similarity index 52% rename from api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommandHandler.kt rename to api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommandHandler.kt index 7159829..88023ee 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommandHandler.kt @@ -1,32 +1,32 @@ package com.jorisjonkers.personalstack.agents.application.command -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository import com.jorisjonkers.personalstack.common.command.CommandHandler import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.time.Instant /** - * Spawns a no-Pod chat session for a user. Returns nothing; the + * Starts a no-Pod conversation for a user. Returns nothing; the * caller passes the freshly generated id in so a follow-up read can * locate the new row. */ @Component -class StartChatSessionCommandHandler( - private val sessions: ChatSessionRepository, -) : CommandHandler { +class StartConversationCommandHandler( + private val conversations: ConversationRepository, +) : CommandHandler { @Transactional - override fun handle(command: StartChatSessionCommand) { + override fun handle(command: StartConversationCommand) { val now = Instant.now() val title = command.title?.trim()?.takeIf { it.isNotEmpty() } - sessions.save( - ChatSession( - id = command.sessionId, + conversations.save( + Conversation( + id = command.conversationId, userId = command.userId, title = title, - status = ChatSessionStatus.ACTIVE, + status = ConversationStatus.ACTIVE, kind = command.kind, createdAt = now, updatedAt = now, diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/query/ChatSessionQueryService.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/query/ChatSessionQueryService.kt deleted file mode 100644 index 1e5ebc4..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/query/ChatSessionQueryService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.jorisjonkers.personalstack.agents.application.query - -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessage -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository -import org.springframework.stereotype.Service -import java.util.UUID - -@Service -class ChatSessionQueryService( - private val sessions: ChatSessionRepository, - private val messages: ChatMessageRepository, -) { - data class ChatSessionDetail( - val session: ChatSession, - val messages: List, - ) - - fun list(userId: UUID): List = sessions.findAllByUserId(userId) - - fun get(id: ChatSessionId): ChatSessionDetail? { - val session = sessions.findById(id) ?: return null - return ChatSessionDetail(session, messages.findAllBySessionIdOrderedByTime(id)) - } -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/query/ConversationQueryService.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/query/ConversationQueryService.kt new file mode 100644 index 0000000..ebc1788 --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/query/ConversationQueryService.kt @@ -0,0 +1,40 @@ +package com.jorisjonkers.personalstack.agents.application.query + +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.port.ConversationMessageRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class ConversationQueryService( + private val conversations: ConversationRepository, + private val messages: ConversationMessageRepository, +) { + private val log = LoggerFactory.getLogger(ConversationQueryService::class.java) + + data class ConversationDetail( + val conversation: Conversation, + val messages: List, + ) + + fun list(userId: UUID): List = conversations.findAllByUserId(userId) + + // Returns nothing for another user's Conversation, identically to an + // unknown id: this is the single seam every handler reads through, so + // ownership can never be forgotten by a caller (see #80). + fun get( + id: ConversationId, + userId: UUID, + ): ConversationDetail? { + val conversation = conversations.findById(id) ?: return null + if (conversation.userId != userId) { + log.warn("refused conversation {} for user {}: not the owner", id.value, userId) + return null + } + return ConversationDetail(conversation, messages.findAllByConversationIdOrderedByTime(id)) + } +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessage.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessage.kt deleted file mode 100644 index 1b9a879..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessage.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.jorisjonkers.personalstack.agents.domain.model - -import java.time.Instant - -data class ChatMessage( - val id: ChatMessageId, - val sessionId: ChatSessionId, - val role: ChatMessageRole, - val body: String, - val createdAt: Instant, -) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessageId.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessageId.kt deleted file mode 100644 index c4411ea..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessageId.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.jorisjonkers.personalstack.agents.domain.model - -import java.util.UUID - -@JvmInline -value class ChatMessageId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - companion object { - fun random(): ChatMessageId = ChatMessageId(UUID.randomUUID()) - - fun parse(s: String): ChatMessageId = ChatMessageId(UUID.fromString(s)) - } -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSession.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSession.kt deleted file mode 100644 index 3948b5e..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSession.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.jorisjonkers.personalstack.agents.domain.model - -import java.time.Instant -import java.util.UUID - -/** - * Chat sessions are the no-Pod conversation surface. They live next - * to [Conversation] — the older type that the legacy chat UI still - * writes against — and exist so the redesigned UI can surface - * "chat" / "scratch" / "workspace" as three sibling tabs without - * coupling each tab to a Pod lifecycle. The legacy `conversations` - * table stays for the rollout window. - */ -data class ChatSession( - val id: ChatSessionId, - val userId: UUID, - val title: String?, - val status: ChatSessionStatus, - val kind: ChatSessionKind, - val createdAt: Instant, - val updatedAt: Instant, -) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionKind.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionKind.kt deleted file mode 100644 index 493e913..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionKind.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.jorisjonkers.personalstack.agents.domain.model - -/** - * What a chat session is wired to talk to. - * - * - [PLAIN] — the existing no-Pod conversation surface: messages are - * persisted and nothing else happens server-side. - * - [KNOWLEDGE] — a session that operates on the knowledge base via an - * agent-runner Pod calling the `knowledge.*` MCP tools. The Pod - * binding + streaming land in a follow-up; this value lets the - * redesigned UI mark a session as KB-mode and lets the backend route - * on it without a second migration later. - */ -enum class ChatSessionKind { - PLAIN, - KNOWLEDGE, -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/Conversation.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/Conversation.kt new file mode 100644 index 0000000..b3a240a --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/Conversation.kt @@ -0,0 +1,20 @@ +package com.jorisjonkers.personalstack.agents.domain.model + +import java.time.Instant +import java.util.UUID + +/** + * The no-Pod chat surface: a chat between the user and an agent that + * has no Workspace. Formerly `ChatSession`, renamed onto this name + * once the legacy `conversation` table (a different, unrelated model) + * was dropped. + */ +data class Conversation( + val id: ConversationId, + val userId: UUID, + val title: String?, + val status: ConversationStatus, + val kind: ConversationKind, + val createdAt: Instant, + val updatedAt: Instant, +) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionId.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationId.kt similarity index 53% rename from api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionId.kt rename to api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationId.kt index 5b9ea89..b7fe3a7 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionId.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationId.kt @@ -3,14 +3,14 @@ package com.jorisjonkers.personalstack.agents.domain.model import java.util.UUID @JvmInline -value class ChatSessionId( +value class ConversationId( val value: UUID, ) { override fun toString(): String = value.toString() companion object { - fun random(): ChatSessionId = ChatSessionId(UUID.randomUUID()) + fun random(): ConversationId = ConversationId(UUID.randomUUID()) - fun parse(s: String): ChatSessionId = ChatSessionId(UUID.fromString(s)) + fun parse(s: String): ConversationId = ConversationId(UUID.fromString(s)) } } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationKind.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationKind.kt new file mode 100644 index 0000000..a5e9cae --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationKind.kt @@ -0,0 +1,17 @@ +package com.jorisjonkers.personalstack.agents.domain.model + +/** + * What a conversation is wired to talk to. + * + * - [PLAIN] — the no-Pod conversation surface: messages are persisted + * and nothing else happens server-side. + * - [KNOWLEDGE] — a conversation that operates on the knowledge base via + * an agent-runner Pod calling the `knowledge.*` MCP tools. The Pod + * binding + streaming land in a follow-up; this value lets the + * redesigned UI mark a conversation as KB-mode and lets the backend + * route on it without a second migration later. + */ +enum class ConversationKind { + PLAIN, + KNOWLEDGE, +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessage.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessage.kt new file mode 100644 index 0000000..910ca4b --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessage.kt @@ -0,0 +1,11 @@ +package com.jorisjonkers.personalstack.agents.domain.model + +import java.time.Instant + +data class ConversationMessage( + val id: ConversationMessageId, + val conversationId: ConversationId, + val role: ConversationMessageRole, + val body: String, + val createdAt: Instant, +) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessageId.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessageId.kt new file mode 100644 index 0000000..00d01f0 --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessageId.kt @@ -0,0 +1,16 @@ +package com.jorisjonkers.personalstack.agents.domain.model + +import java.util.UUID + +@JvmInline +value class ConversationMessageId( + val value: UUID, +) { + override fun toString(): String = value.toString() + + companion object { + fun random(): ConversationMessageId = ConversationMessageId(UUID.randomUUID()) + + fun parse(s: String): ConversationMessageId = ConversationMessageId(UUID.fromString(s)) + } +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessageRole.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessageRole.kt similarity index 72% rename from api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessageRole.kt rename to api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessageRole.kt index 30cc64e..52f7400 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatMessageRole.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationMessageRole.kt @@ -1,6 +1,6 @@ package com.jorisjonkers.personalstack.agents.domain.model -enum class ChatMessageRole { +enum class ConversationMessageRole { USER, ASSISTANT, SYSTEM, diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionStatus.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationStatus.kt similarity index 73% rename from api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionStatus.kt rename to api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationStatus.kt index 717e9ef..55f377b 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ChatSessionStatus.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/ConversationStatus.kt @@ -1,6 +1,6 @@ package com.jorisjonkers.personalstack.agents.domain.model -enum class ChatSessionStatus { +enum class ConversationStatus { ACTIVE, ARCHIVED, } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/Workspace.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/Workspace.kt index 631ce0e..00be8cd 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/Workspace.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/Workspace.kt @@ -14,7 +14,7 @@ enum class RunnerSetupOperation { IDLE, RESTARTING, FAILED } * a workspace-scoped k8s Secret. * - `SCRATCH` — Pod without a clone. `repositoryId` is null. * - `CHAT` — never actually persisted as a workspace row - * today; chat lives in its own [ChatSession] table. The enum + * today; chat lives in its own [Conversation] table. The enum * value exists for type-symmetry with the UI tabs. * * `projectId` is set when the workspace was opened "inside a diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/WorkspaceKind.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/WorkspaceKind.kt index 6f2afbb..46ac252 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/WorkspaceKind.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/model/WorkspaceKind.kt @@ -7,7 +7,7 @@ package com.jorisjonkers.personalstack.agents.domain.model * - `SCRATCH` — Pod without a clone. Repository is null. * - `CHAT` — no Pod. A plain LLM conversation surface that * lives outside the workspaces table (see - * [ChatSession]); the enum value exists here so a + * [Conversation]); the enum value exists here so a * workspace row with `kind = CHAT` can never be * created — the type system gives the orchestrator * a single value to switch on. diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ChatMessageRepository.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ChatMessageRepository.kt deleted file mode 100644 index f787080..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ChatMessageRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.jorisjonkers.personalstack.agents.domain.port - -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessage -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId - -interface ChatMessageRepository { - fun save(message: ChatMessage): ChatMessage - - fun findById(id: ChatMessageId): ChatMessage? - - fun findAllBySessionIdOrderedByTime(sessionId: ChatSessionId): List - - fun deleteAllBySessionId(sessionId: ChatSessionId) -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ChatSessionRepository.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ChatSessionRepository.kt deleted file mode 100644 index 8040505..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ChatSessionRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.jorisjonkers.personalstack.agents.domain.port - -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import java.util.UUID - -interface ChatSessionRepository { - fun save(session: ChatSession): ChatSession - - fun findById(id: ChatSessionId): ChatSession? - - fun findAllByUserId(userId: UUID): List - - fun delete(id: ChatSessionId) -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ConversationMessageRepository.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ConversationMessageRepository.kt new file mode 100644 index 0000000..814471f --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ConversationMessageRepository.kt @@ -0,0 +1,15 @@ +package com.jorisjonkers.personalstack.agents.domain.port + +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId + +interface ConversationMessageRepository { + fun save(message: ConversationMessage): ConversationMessage + + fun findById(id: ConversationMessageId): ConversationMessage? + + fun findAllByConversationIdOrderedByTime(conversationId: ConversationId): List + + fun deleteAllByConversationId(conversationId: ConversationId) +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ConversationRepository.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ConversationRepository.kt new file mode 100644 index 0000000..60e229a --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/domain/port/ConversationRepository.kt @@ -0,0 +1,15 @@ +package com.jorisjonkers.personalstack.agents.domain.port + +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import java.util.UUID + +interface ConversationRepository { + fun save(conversation: Conversation): Conversation + + fun findById(id: ConversationId): Conversation? + + fun findAllByUserId(userId: UUID): List + + fun delete(id: ConversationId) +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqChatMessageRepository.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqChatMessageRepository.kt deleted file mode 100644 index c875409..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqChatMessageRepository.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.jorisjonkers.personalstack.agents.infrastructure.persistence - -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessage -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.port.ChatMessageRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqChatMessageRepository( - private val dsl: DSLContext, -) : ChatMessageRepository { - override fun save(message: ChatMessage): ChatMessage { - val createdAt = message.createdAt.atOffset(ZoneOffset.UTC) - dsl - .insertInto(TABLE) - .set(ID, message.id.value) - .set(SESSION_ID, message.sessionId.value) - .set(ROLE, message.role.name) - .set(BODY, message.body) - .set(CREATED_AT, createdAt) - .onConflict(ID) - .doUpdate() - .set(BODY, message.body) - .execute() - return message - } - - override fun findById(id: ChatMessageId): ChatMessage? = - dsl - .selectFrom(TABLE) - .where(ID.eq(id.value)) - .fetchOne() - ?.toMessage() - - override fun findAllBySessionIdOrderedByTime(sessionId: ChatSessionId): List = - dsl - .selectFrom(TABLE) - .where(SESSION_ID.eq(sessionId.value)) - .orderBy(CREATED_AT.asc()) - .fetch() - .map { it.toMessage() } - - override fun deleteAllBySessionId(sessionId: ChatSessionId) { - dsl.deleteFrom(TABLE).where(SESSION_ID.eq(sessionId.value)).execute() - } - - private fun Record.toMessage(): ChatMessage = - ChatMessage( - id = ChatMessageId(this[ID]), - sessionId = ChatSessionId(this[SESSION_ID]), - role = ChatMessageRole.valueOf(this[ROLE]), - body = this[BODY], - createdAt = this[CREATED_AT].toInstant(), - ) - - companion object { - @JvmStatic val TABLE = DSL.table("chat_session_messages") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val SESSION_ID = DSL.field("chat_session_id", UUID::class.java) - - @JvmStatic val ROLE = DSL.field("role", String::class.java) - - @JvmStatic val BODY = DSL.field("body", String::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - } -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqConversationMessageRepository.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqConversationMessageRepository.kt new file mode 100644 index 0000000..c7ec13e --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqConversationMessageRepository.kt @@ -0,0 +1,77 @@ +package com.jorisjonkers.personalstack.agents.infrastructure.persistence + +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole +import com.jorisjonkers.personalstack.agents.domain.port.ConversationMessageRepository +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.impl.DSL +import org.springframework.stereotype.Repository +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +@Repository +class JooqConversationMessageRepository( + private val dsl: DSLContext, +) : ConversationMessageRepository { + override fun save(message: ConversationMessage): ConversationMessage { + val createdAt = message.createdAt.atOffset(ZoneOffset.UTC) + dsl + .insertInto(TABLE) + .set(ID, message.id.value) + .set(CONVERSATION_ID, message.conversationId.value) + .set(ROLE, message.role.name) + .set(BODY, message.body) + .set(CREATED_AT, createdAt) + .onConflict(ID) + .doUpdate() + .set(BODY, message.body) + .execute() + return message + } + + override fun findById(id: ConversationMessageId): ConversationMessage? = + dsl + .selectFrom(TABLE) + .where(ID.eq(id.value)) + .fetchOne() + ?.toMessage() + + override fun findAllByConversationIdOrderedByTime(conversationId: ConversationId): List = + dsl + .selectFrom(TABLE) + .where(CONVERSATION_ID.eq(conversationId.value)) + .orderBy(CREATED_AT.asc()) + .fetch() + .map { it.toMessage() } + + override fun deleteAllByConversationId(conversationId: ConversationId) { + dsl.deleteFrom(TABLE).where(CONVERSATION_ID.eq(conversationId.value)).execute() + } + + private fun Record.toMessage(): ConversationMessage = + ConversationMessage( + id = ConversationMessageId(this[ID]), + conversationId = ConversationId(this[CONVERSATION_ID]), + role = ConversationMessageRole.valueOf(this[ROLE]), + body = this[BODY], + createdAt = this[CREATED_AT].toInstant(), + ) + + companion object { + @JvmStatic val TABLE = DSL.table("conversation_messages") + + @JvmStatic val ID = DSL.field("id", UUID::class.java) + + @JvmStatic val CONVERSATION_ID = DSL.field("conversation_id", UUID::class.java) + + @JvmStatic val ROLE = DSL.field("role", String::class.java) + + @JvmStatic val BODY = DSL.field("body", String::class.java) + + @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) + } +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqChatSessionRepository.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqConversationRepository.kt similarity index 51% rename from api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqChatSessionRepository.kt rename to api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqConversationRepository.kt index 8a93bff..f3c04e2 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqChatSessionRepository.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/persistence/JooqConversationRepository.kt @@ -1,10 +1,10 @@ package com.jorisjonkers.personalstack.agents.infrastructure.persistence -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository import org.jooq.DSLContext import org.jooq.Record import org.jooq.impl.DSL @@ -14,62 +14,62 @@ import java.time.ZoneOffset import java.util.UUID @Repository -class JooqChatSessionRepository( +class JooqConversationRepository( private val dsl: DSLContext, -) : ChatSessionRepository { - override fun save(session: ChatSession): ChatSession { - val createdAt = session.createdAt.atOffset(ZoneOffset.UTC) - val updatedAt = session.updatedAt.atOffset(ZoneOffset.UTC) +) : ConversationRepository { + override fun save(conversation: Conversation): Conversation { + val createdAt = conversation.createdAt.atOffset(ZoneOffset.UTC) + val updatedAt = conversation.updatedAt.atOffset(ZoneOffset.UTC) dsl .insertInto(TABLE) - .set(ID, session.id.value) - .set(USER_ID, session.userId) - .set(TITLE, session.title) - .set(STATUS, session.status.name) - .set(KIND, session.kind.name) + .set(ID, conversation.id.value) + .set(USER_ID, conversation.userId) + .set(TITLE, conversation.title) + .set(STATUS, conversation.status.name) + .set(KIND, conversation.kind.name) .set(CREATED_AT, createdAt) .set(UPDATED_AT, updatedAt) .onConflict(ID) .doUpdate() - .set(TITLE, session.title) - .set(STATUS, session.status.name) + .set(TITLE, conversation.title) + .set(STATUS, conversation.status.name) .set(UPDATED_AT, updatedAt) .execute() - return session + return conversation } - override fun findById(id: ChatSessionId): ChatSession? = + override fun findById(id: ConversationId): Conversation? = dsl .selectFrom(TABLE) .where(ID.eq(id.value)) .fetchOne() - ?.toSession() + ?.toConversation() - override fun findAllByUserId(userId: UUID): List = + override fun findAllByUserId(userId: UUID): List = dsl .selectFrom(TABLE) .where(USER_ID.eq(userId)) .orderBy(CREATED_AT.desc()) .fetch() - .map { it.toSession() } + .map { it.toConversation() } - override fun delete(id: ChatSessionId) { + override fun delete(id: ConversationId) { dsl.deleteFrom(TABLE).where(ID.eq(id.value)).execute() } - private fun Record.toSession(): ChatSession = - ChatSession( - id = ChatSessionId(this[ID]), + private fun Record.toConversation(): Conversation = + Conversation( + id = ConversationId(this[ID]), userId = this[USER_ID], title = this[TITLE], - status = ChatSessionStatus.valueOf(this[STATUS]), - kind = ChatSessionKind.valueOf(this[KIND]), + status = ConversationStatus.valueOf(this[STATUS]), + kind = ConversationKind.valueOf(this[KIND]), createdAt = this[CREATED_AT].toInstant(), updatedAt = this[UPDATED_AT].toInstant(), ) companion object { - @JvmStatic val TABLE = DSL.table("chat_sessions") + @JvmStatic val TABLE = DSL.table("conversations") @JvmStatic val ID = DSL.field("id", UUID::class.java) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ChatSessionController.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ChatSessionController.kt index eb9813f..75d0e7c 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ChatSessionController.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ChatSessionController.kt @@ -1,13 +1,13 @@ package com.jorisjonkers.personalstack.agents.infrastructure.web import com.jorisjonkers.personalstack.agents.application.chat.ChatAnswerStreamService -import com.jorisjonkers.personalstack.agents.application.command.AppendChatMessageCommand -import com.jorisjonkers.personalstack.agents.application.command.ArchiveChatSessionCommand -import com.jorisjonkers.personalstack.agents.application.command.StartChatSessionCommand -import com.jorisjonkers.personalstack.agents.application.query.ChatSessionQueryService -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind +import com.jorisjonkers.personalstack.agents.application.command.AppendConversationMessageCommand +import com.jorisjonkers.personalstack.agents.application.command.ArchiveConversationCommand +import com.jorisjonkers.personalstack.agents.application.command.StartConversationCommand +import com.jorisjonkers.personalstack.agents.application.query.ConversationQueryService +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.AppendChatMessageRequest import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.ChatMessageResponse import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.ChatSessionResponse @@ -29,88 +29,118 @@ import org.springframework.web.bind.annotation.RestController import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import java.util.UUID +/** + * Deprecated alias for [ConversationController] at the old path. + * Serves the same Conversation model with the pre-rename shapes so + * agents-ui keeps working unchanged; remove once agents-ui migrates to + * /api/v1/conversations. + */ @RestController @RequestMapping("/api/v1/chat-sessions") class ChatSessionController( private val commandBus: CommandBus, - private val chatSessionQuery: ChatSessionQueryService, + private val conversationQuery: ConversationQueryService, private val chatAnswerStream: ChatAnswerStreamService, ) { @PostMapping + @Deprecated("Use POST /api/v1/conversations.") fun create( @RequestHeader("X-User-Id") userId: String, @Valid @RequestBody req: StartChatSessionRequest, ): ResponseEntity { val userUuid = UUID.fromString(userId) - val sessionId = ChatSessionId.random() + val conversationId = ConversationId.random() commandBus.dispatch( - StartChatSessionCommand( - sessionId = sessionId, + StartConversationCommand( + conversationId = conversationId, userId = userUuid, title = req.title, - kind = req.kind ?: ChatSessionKind.PLAIN, + kind = req.kind ?: ConversationKind.PLAIN, ), ) val detail = - chatSessionQuery.get(sessionId) + conversationQuery.get(conversationId, userUuid) ?: error("chat session not visible immediately after create") - return ResponseEntity.status(HttpStatus.CREATED).body(ChatSessionResponse.of(detail.session)) + return ResponseEntity.status(HttpStatus.CREATED).body(ChatSessionResponse.of(detail.conversation)) } @GetMapping + @Deprecated("Use GET /api/v1/conversations.") fun list( @RequestHeader("X-User-Id") userId: String, ): List = - chatSessionQuery + conversationQuery .list(UUID.fromString(userId)) .map(ChatSessionResponse::of) + // These two predate #80 and carried no identity, so declaring the header + // required would break their published contract. Absent or unparseable + // identity refuses exactly like a wrong one: 404, never a hint. + private fun callerId(userId: String?): UUID? = + userId?.takeIf { it.isNotBlank() }?.let { runCatching { UUID.fromString(it) }.getOrNull() } + @GetMapping("/{id}") + @Deprecated("Use GET /api/v1/conversations/{id}, which returns a typed ConversationDetailResponse.") fun get( @PathVariable id: UUID, + @RequestHeader("X-User-Id", required = false) userId: String?, ): ResponseEntity> { - val detail = chatSessionQuery.get(ChatSessionId(id)) ?: return ResponseEntity.notFound().build() + val userUuid = callerId(userId) ?: return ResponseEntity.notFound().build() + val detail = + conversationQuery.get(ConversationId(id), userUuid) + ?: return ResponseEntity.notFound().build() return ResponseEntity.ok( mapOf( - "session" to ChatSessionResponse.of(detail.session), + "session" to ChatSessionResponse.of(detail.conversation), "messages" to detail.messages.map(ChatMessageResponse::of), ), ) } @PostMapping("/{id}/messages") + @Deprecated("Use POST /api/v1/conversations/{id}/messages.") fun appendMessage( @PathVariable id: UUID, + @RequestHeader("X-User-Id", required = false) userId: String?, @Valid @RequestBody req: AppendChatMessageRequest, ): ResponseEntity { - val messageId = ChatMessageId.random() + val conversationId = ConversationId(id) + // Resolve the caller and their ownership in one step, before anything + // is dispatched: the alias delegates to ConversationQueryService so it + // inherits the check rather than reimplementing it (see #80). + val userUuid = + callerId(userId)?.takeIf { conversationQuery.get(conversationId, it) != null } + ?: return ResponseEntity.notFound().build() + val messageId = ConversationMessageId.random() commandBus.dispatch( - AppendChatMessageCommand( + AppendConversationMessageCommand( messageId = messageId, - sessionId = ChatSessionId(id), + conversationId = conversationId, role = req.role, body = req.body, ), ) - val detail = chatSessionQuery.get(ChatSessionId(id)) ?: return ResponseEntity.notFound().build() + val detail = conversationQuery.get(conversationId, userUuid) ?: return ResponseEntity.notFound().build() val message = detail.messages.firstOrNull { it.id == messageId } ?: error("message not visible immediately after append") return ResponseEntity.status(HttpStatus.CREATED).body(ChatMessageResponse.of(message)) } - // Excluded from the OpenAPI contract: an SSE/text-event-stream - // endpoint cannot be modelled usefully by openapi-typescript, and the - // UI consumes it through a hand-written fetch + ReadableStream reader - // rather than the generated client. Keeping it out of the spec leaves - // the generated types in sync without a degenerate stream type. + // Excluded from the OpenAPI contract for the same reason as the + // canonical controller's stream endpoint (see there); this alias + // exists purely so an in-flight stream connection from an old + // client keeps working. @Hidden @PostMapping("/{id}/messages/stream") fun streamMessage( @PathVariable id: UUID, + @RequestHeader("X-User-Id") userId: String, @Valid @RequestBody req: AppendChatMessageRequest, ): ResponseEntity { - val emitter = chatAnswerStream.stream(ChatSessionId(id), req.body) + val conversationId = ConversationId(id) + conversationQuery.get(conversationId, UUID.fromString(userId)) ?: return ResponseEntity.notFound().build() + val emitter = chatAnswerStream.stream(conversationId, req.body) return ResponseEntity .ok() .contentType(MediaType.TEXT_EVENT_STREAM) @@ -120,13 +150,14 @@ class ChatSessionController( } @DeleteMapping("/{id}") + @Deprecated("Use DELETE /api/v1/conversations/{id}.") fun archive( @PathVariable id: UUID, @RequestHeader("X-User-Id") userId: String, ): ResponseEntity { commandBus.dispatch( - ArchiveChatSessionCommand( - sessionId = ChatSessionId(id), + ArchiveConversationCommand( + conversationId = ConversationId(id), userId = UUID.fromString(userId), ), ) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ConversationController.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ConversationController.kt new file mode 100644 index 0000000..f877b91 --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ConversationController.kt @@ -0,0 +1,170 @@ +package com.jorisjonkers.personalstack.agents.infrastructure.web + +import com.jorisjonkers.personalstack.agents.application.chat.ChatAnswerStreamService +import com.jorisjonkers.personalstack.agents.application.command.AppendConversationMessageCommand +import com.jorisjonkers.personalstack.agents.application.command.ArchiveConversationCommand +import com.jorisjonkers.personalstack.agents.application.command.StartConversationCommand +import com.jorisjonkers.personalstack.agents.application.query.ConversationQueryService +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId +import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.AppendConversationMessageRequest +import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.ConversationDetailResponse +import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.ConversationMessageResponse +import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.ConversationResponse +import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.StartConversationRequest +import com.jorisjonkers.personalstack.common.command.CommandBus +import io.swagger.v3.oas.annotations.Hidden +import io.swagger.v3.oas.annotations.responses.ApiResponse +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/conversations") +class ConversationController( + private val commandBus: CommandBus, + private val conversationQuery: ConversationQueryService, + private val chatAnswerStream: ChatAnswerStreamService, +) { + @PostMapping + // springdoc can't infer the status from a dynamically built + // ResponseEntity; document the real 201 so the spec is truthful. + @ApiResponse(responseCode = "201", description = "Created") + fun create( + @RequestHeader("X-User-Id") userId: String, + @Valid @RequestBody req: StartConversationRequest, + ): ResponseEntity { + val userUuid = UUID.fromString(userId) + val conversationId = ConversationId.random() + commandBus.dispatch( + StartConversationCommand( + conversationId = conversationId, + userId = userUuid, + title = req.title, + kind = req.kind ?: ConversationKind.PLAIN, + ), + ) + val detail = + conversationQuery.get(conversationId, userUuid) + ?: error("conversation not visible immediately after create") + return ResponseEntity.status(HttpStatus.CREATED).body(ConversationResponse.of(detail.conversation)) + } + + @GetMapping + fun list( + @RequestHeader("X-User-Id") userId: String, + ): List = + conversationQuery + .list(UUID.fromString(userId)) + .map(ConversationResponse::of) + + @GetMapping("/{id}") + fun get( + @PathVariable id: UUID, + @RequestHeader("X-User-Id") userId: String, + ): ResponseEntity { + val detail = + conversationQuery.get(ConversationId(id), UUID.fromString(userId)) + ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(ConversationDetailResponse.of(detail.conversation, detail.messages)) + } + + // Restores the legacy /api/v1/conversations/{conversationId}/messages + // list endpoint, serving the renamed model. Ownership is enforced + // through the same seam as GET /{id} (see #80): an unowned + // conversation reads as 404, not an empty list. + @GetMapping("/{id}/messages") + fun listMessages( + @PathVariable id: UUID, + @RequestHeader("X-User-Id") userId: String, + ): ResponseEntity> { + val detail = + conversationQuery.get(ConversationId(id), UUID.fromString(userId)) + ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(detail.messages.map(ConversationMessageResponse::of)) + } + + @PostMapping("/{id}/messages") + // springdoc can't infer the status from a dynamically built + // ResponseEntity; document the real 201 so the spec is truthful. + @ApiResponse(responseCode = "201", description = "Created") + fun appendMessage( + @PathVariable id: UUID, + @RequestHeader("X-User-Id") userId: String, + @Valid @RequestBody req: AppendConversationMessageRequest, + ): ResponseEntity { + val userUuid = UUID.fromString(userId) + val conversationId = ConversationId(id) + // Ownership is resolved before dispatch, so a refused request has + // no side effect (see #80). + conversationQuery.get(conversationId, userUuid) ?: return ResponseEntity.notFound().build() + val messageId = ConversationMessageId.random() + commandBus.dispatch( + AppendConversationMessageCommand( + messageId = messageId, + conversationId = conversationId, + role = req.resolvedRole(), + body = req.resolvedBody(), + ), + ) + val detail = conversationQuery.get(conversationId, userUuid) ?: return ResponseEntity.notFound().build() + val message = + detail.messages.firstOrNull { it.id == messageId } + ?: error("message not visible immediately after append") + return ResponseEntity.status(HttpStatus.CREATED).body(ConversationMessageResponse.of(message)) + } + + // Excluded from the OpenAPI contract: an SSE/text-event-stream + // endpoint cannot be modelled usefully by openapi-typescript, and the + // UI consumes it through a hand-written fetch + ReadableStream reader + // rather than the generated client. Keeping it out of the spec leaves + // the generated types in sync without a degenerate stream type. + @Hidden + @PostMapping("/{id}/messages/stream") + fun streamMessage( + @PathVariable id: UUID, + @RequestHeader("X-User-Id") userId: String, + @Valid @RequestBody req: AppendConversationMessageRequest, + ): ResponseEntity { + val conversationId = ConversationId(id) + // Ownership is resolved before streaming starts, so a refused + // request never drives generation or token spend (see #80). + conversationQuery.get(conversationId, UUID.fromString(userId)) ?: return ResponseEntity.notFound().build() + val emitter = chatAnswerStream.stream(conversationId, req.resolvedBody()) + return ResponseEntity + .ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .header("Cache-Control", "no-cache") + .header("X-Accel-Buffering", "no") + .body(emitter) + } + + @DeleteMapping("/{id}") + // springdoc can't infer the status from a dynamically built + // ResponseEntity; document the real 204 so the spec is truthful. + @ApiResponse(responseCode = "204", description = "No Content") + fun archive( + @PathVariable id: UUID, + @RequestHeader("X-User-Id") userId: String, + ): ResponseEntity { + commandBus.dispatch( + ArchiveConversationCommand( + conversationId = ConversationId(id), + userId = UUID.fromString(userId), + ), + ) + return ResponseEntity.noContent().build() + } +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ChatSessionAliasDtos.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ChatSessionAliasDtos.kt new file mode 100644 index 0000000..0bf51a2 --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ChatSessionAliasDtos.kt @@ -0,0 +1,68 @@ +package com.jorisjonkers.personalstack.agents.infrastructure.web.dto + +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import java.time.Instant +import java.util.UUID + +// Wire-compat DTOs for the deprecated /api/v1/chat-sessions alias +// (ChatSessionController). Kept separate from ConversationDtos so the +// canonical shapes (ConversationMessageResponse.conversationId, the +// typed ConversationDetailResponse) can change without moving this +// surface, which agents-ui still depends on byte-for-byte. + +data class StartChatSessionRequest( + @field:Size(max = 120) val title: String? = null, + val kind: ConversationKind? = null, +) + +data class ChatSessionResponse( + val id: UUID, + val userId: UUID, + val title: String?, + val status: String, + val kind: String, + val createdAt: Instant, + val updatedAt: Instant, +) { + companion object { + fun of(c: Conversation) = + ChatSessionResponse( + id = c.id.value, + userId = c.userId, + title = c.title, + status = c.status.name, + kind = c.kind.name, + createdAt = c.createdAt, + updatedAt = c.updatedAt, + ) + } +} + +data class AppendChatMessageRequest( + @field:NotBlank val body: String, + val role: ConversationMessageRole = ConversationMessageRole.USER, +) + +data class ChatMessageResponse( + val id: UUID, + val sessionId: UUID, + val role: String, + val body: String, + val createdAt: Instant, +) { + companion object { + fun of(m: ConversationMessage) = + ChatMessageResponse( + id = m.id.value, + sessionId = m.conversationId.value, + role = m.role.name, + body = m.body, + createdAt = m.createdAt, + ) + } +} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ChatSessionDtos.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ChatSessionDtos.kt deleted file mode 100644 index 3530cef..0000000 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ChatSessionDtos.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.jorisjonkers.personalstack.agents.infrastructure.web.dto - -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessage -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Size -import java.time.Instant -import java.util.UUID - -data class StartChatSessionRequest( - @field:Size(max = 120) val title: String? = null, - // Nullable so an absent field deserializes to null on any Jackson - // setup (a non-null Kotlin default is not honored for a missing - // property); the controller coalesces null to PLAIN. - val kind: ChatSessionKind? = null, -) - -data class ChatSessionResponse( - val id: UUID, - val userId: UUID, - val title: String?, - val status: String, - val kind: String, - val createdAt: Instant, - val updatedAt: Instant, -) { - companion object { - fun of(s: ChatSession) = - ChatSessionResponse( - id = s.id.value, - userId = s.userId, - title = s.title, - status = s.status.name, - kind = s.kind.name, - createdAt = s.createdAt, - updatedAt = s.updatedAt, - ) - } -} - -data class AppendChatMessageRequest( - @field:NotBlank val body: String, - val role: ChatMessageRole = ChatMessageRole.USER, -) - -data class ChatMessageResponse( - val id: UUID, - val sessionId: UUID, - val role: String, - val body: String, - val createdAt: Instant, -) { - companion object { - fun of(m: ChatMessage) = - ChatMessageResponse( - id = m.id.value, - sessionId = m.sessionId.value, - role = m.role.name, - body = m.body, - createdAt = m.createdAt, - ) - } -} diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ConversationDtos.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ConversationDtos.kt new file mode 100644 index 0000000..0e2bfec --- /dev/null +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/dto/ConversationDtos.kt @@ -0,0 +1,109 @@ +package com.jorisjonkers.personalstack.agents.infrastructure.web.dto + +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole +import jakarta.validation.constraints.Size +import java.time.Instant +import java.util.UUID + +data class StartConversationRequest( + @field:Size(max = 120) val title: String? = null, + // Nullable so an absent field deserializes to null on any Jackson + // setup (a non-null Kotlin default is not honored for a missing + // property); the controller coalesces null to PLAIN. + val kind: ConversationKind? = null, +) + +data class ConversationResponse( + val id: UUID, + val userId: UUID, + val title: String?, + val status: String, + val kind: String, + val createdAt: Instant, + val updatedAt: Instant, +) { + companion object { + fun of(c: Conversation) = + ConversationResponse( + id = c.id.value, + userId = c.userId, + title = c.title, + status = c.status.name, + kind = c.kind.name, + createdAt = c.createdAt, + updatedAt = c.updatedAt, + ) + } +} + +// body/content both nullable (rather than one @NotBlank field) so +// either the canonical `body` or the legacy `content` name can be +// absent without failing bean validation; resolvedBody() picks +// whichever is present and the handler still rejects a blank result. +data class AppendConversationMessageRequest( + val body: String? = null, + val content: String? = null, + val role: ConversationMessageRole? = null, +) { + fun resolvedBody(): String = (body ?: content).orEmpty() + + fun resolvedRole(): ConversationMessageRole = role ?: ConversationMessageRole.USER +} + +data class ConversationMessageResponse( + val id: UUID, + val conversationId: UUID, + val role: String, + val body: String, + // Wire-compat: the legacy /api/v1/conversations response used + // `content`; duplicated alongside `body` so an old reader of the + // canonical path keeps working. + val content: String, + val createdAt: Instant, +) { + companion object { + fun of(m: ConversationMessage) = + ConversationMessageResponse( + id = m.id.value, + conversationId = m.conversationId.value, + role = m.role.name, + body = m.body, + content = m.body, + createdAt = m.createdAt, + ) + } +} + +// Flat shape restored to match the legacy /api/v1/conversations/{id} +// response (id/userId/title/status/createdAt/updatedAt at top level); +// only `kind` and `messages` are additions. The deprecated +// /api/v1/chat-sessions alias keeps its own untyped map instead. +data class ConversationDetailResponse( + val id: UUID, + val userId: UUID, + val title: String?, + val status: String, + val kind: String, + val createdAt: Instant, + val updatedAt: Instant, + val messages: List, +) { + companion object { + fun of( + conversation: Conversation, + messages: List, + ) = ConversationDetailResponse( + id = conversation.id.value, + userId = conversation.userId, + title = conversation.title, + status = conversation.status.name, + kind = conversation.kind.name, + createdAt = conversation.createdAt, + updatedAt = conversation.updatedAt, + messages = messages.map(ConversationMessageResponse::of), + ) + } +} diff --git a/api/src/main/resources/db/migration/V27__rename_chat_sessions_to_conversations.sql b/api/src/main/resources/db/migration/V27__rename_chat_sessions_to_conversations.sql new file mode 100644 index 0000000..4936757 --- /dev/null +++ b/api/src/main/resources/db/migration/V27__rename_chat_sessions_to_conversations.sql @@ -0,0 +1,8 @@ +-- The no-Pod chat surface takes over the `Conversation` name now that +-- the legacy model is gone (V26). RENAME preserves every row -- the +-- point of doing this in-place rather than as a drop/recreate is that +-- existing chats remain available afterwards, just addressed through +-- the new name. +ALTER TABLE chat_sessions RENAME TO conversations; +ALTER TABLE chat_session_messages RENAME TO conversation_messages; +ALTER TABLE conversation_messages RENAME COLUMN chat_session_id TO conversation_id; diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/chat/ChatAnswerStreamServiceTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/chat/ChatAnswerStreamServiceTest.kt index df40892..704a5b6 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/chat/ChatAnswerStreamServiceTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/chat/ChatAnswerStreamServiceTest.kt @@ -1,16 +1,16 @@ package com.jorisjonkers.personalstack.agents.application.chat -import com.jorisjonkers.personalstack.agents.application.command.AppendChatMessageCommand -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessage -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus +import com.jorisjonkers.personalstack.agents.application.command.AppendConversationMessageCommand +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus import com.jorisjonkers.personalstack.agents.domain.port.ChatGenerationPort -import com.jorisjonkers.personalstack.agents.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationMessageRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository import com.jorisjonkers.personalstack.common.command.CommandBus import io.mockk.every import io.mockk.just @@ -24,19 +24,19 @@ import java.util.UUID import java.util.concurrent.Executor class ChatAnswerStreamServiceTest { - private val sessions = mockk() - private val messages = mockk() + private val conversations = mockk() + private val messages = mockk() private val commandBus = mockk() private val generation = mockk() private val executor = Executor { it.run() } - private val service = ChatAnswerStreamService(sessions, messages, commandBus, generation, executor) + private val service = ChatAnswerStreamService(conversations, messages, commandBus, generation, executor) @Test fun `stream persists agent message when an answer is produced`() { - val session = session() - val commands = mutableListOf() - every { sessions.findById(session.id) } returns session - every { messages.findAllBySessionIdOrderedByTime(session.id) } returns emptyList() + val conversation = conversation() + val commands = mutableListOf() + every { conversations.findById(conversation.id) } returns conversation + every { messages.findAllByConversationIdOrderedByTime(conversation.id) } returns emptyList() every { commandBus.dispatch(capture(commands)) } just runs every { generation.generate("Hi", any()) } answers { secondArg<(String) -> Unit>().invoke("Hel") @@ -44,57 +44,57 @@ class ChatAnswerStreamServiceTest { "Hello" } - service.stream(session.id, "Hi") + service.stream(conversation.id, "Hi") assertThat(commands).hasSize(1) - assertThat(commands[0].sessionId).isEqualTo(session.id) - assertThat(commands[0].role).isEqualTo(ChatMessageRole.ASSISTANT) + assertThat(commands[0].conversationId).isEqualTo(conversation.id) + assertThat(commands[0].role).isEqualTo(ConversationMessageRole.ASSISTANT) assertThat(commands[0].body).isEqualTo("Hello") } @Test fun `stream persists no agent message when no answer is produced`() { - val session = session() - val commands = mutableListOf() - every { sessions.findById(session.id) } returns session - every { messages.findAllBySessionIdOrderedByTime(session.id) } returns emptyList() + val conversation = conversation() + val commands = mutableListOf() + every { conversations.findById(conversation.id) } returns conversation + every { messages.findAllByConversationIdOrderedByTime(conversation.id) } returns emptyList() every { commandBus.dispatch(capture(commands)) } just runs every { generation.generate("Hi", any()) } returns "" - service.stream(session.id, "Hi") + service.stream(conversation.id, "Hi") assertThat(commands).isEmpty() } @Test fun `stream routes generation through ChatGenerationPort not LightRagClient`() { - val session = session() + val conversation = conversation() val promptSlot = slot() - every { sessions.findById(session.id) } returns session - every { messages.findAllBySessionIdOrderedByTime(session.id) } returns emptyList() + every { conversations.findById(conversation.id) } returns conversation + every { messages.findAllByConversationIdOrderedByTime(conversation.id) } returns emptyList() every { commandBus.dispatch(any()) } just runs every { generation.generate(capture(promptSlot), any()) } returns "Answer" - service.stream(session.id, "Hello") + service.stream(conversation.id, "Hello") assertThat(promptSlot.captured).isEqualTo("Hello") } @Test - fun `prompt includes prior history turns when session has messages`() { - val session = session() + fun `prompt includes prior history turns when conversation has messages`() { + val conversation = conversation() val promptSlot = slot() val history = listOf( - chatMessage(session.id, ChatMessageRole.USER, "What is Kotlin?"), - chatMessage(session.id, ChatMessageRole.ASSISTANT, "A JVM language."), + conversationMessage(conversation.id, ConversationMessageRole.USER, "What is Kotlin?"), + conversationMessage(conversation.id, ConversationMessageRole.ASSISTANT, "A JVM language."), ) - every { sessions.findById(session.id) } returns session - every { messages.findAllBySessionIdOrderedByTime(session.id) } returns history + every { conversations.findById(conversation.id) } returns conversation + every { messages.findAllByConversationIdOrderedByTime(conversation.id) } returns history every { commandBus.dispatch(any()) } just runs every { generation.generate(capture(promptSlot), any()) } returns "Answer" - service.stream(session.id, "Tell me more") + service.stream(conversation.id, "Tell me more") assertThat(promptSlot.captured).contains("User: What is Kotlin?") assertThat(promptSlot.captured).contains("Assistant: A JVM language.") @@ -103,32 +103,32 @@ class ChatAnswerStreamServiceTest { @Test fun `prompt with no history is just the user body`() { - val session = session() + val conversation = conversation() val promptSlot = slot() - every { sessions.findById(session.id) } returns session - every { messages.findAllBySessionIdOrderedByTime(session.id) } returns emptyList() + every { conversations.findById(conversation.id) } returns conversation + every { messages.findAllByConversationIdOrderedByTime(conversation.id) } returns emptyList() every { commandBus.dispatch(any()) } just runs every { generation.generate(capture(promptSlot), any()) } returns "Answer" - service.stream(session.id, "Hello") + service.stream(conversation.id, "Hello") assertThat(promptSlot.captured).isEqualTo("Hello") } @Test fun `history is bounded to the most recent 20 messages`() { - val session = session() + val conversation = conversation() val promptSlot = slot() val history = (1..25).map { i -> - chatMessage(session.id, ChatMessageRole.USER, "Message $i") + conversationMessage(conversation.id, ConversationMessageRole.USER, "Message $i") } - every { sessions.findById(session.id) } returns session - every { messages.findAllBySessionIdOrderedByTime(session.id) } returns history + every { conversations.findById(conversation.id) } returns conversation + every { messages.findAllByConversationIdOrderedByTime(conversation.id) } returns history every { commandBus.dispatch(any()) } just runs every { generation.generate(capture(promptSlot), any()) } returns "Answer" - service.stream(session.id, "Latest") + service.stream(conversation.id, "Latest") val captured = promptSlot.captured assertThat(captured).doesNotContain("User: Message 4\n") @@ -140,20 +140,20 @@ class ChatAnswerStreamServiceTest { @Test fun `current user turn is not duplicated when already persisted in history`() { - val session = session() + val conversation = conversation() val promptSlot = slot() val history = listOf( - chatMessage(session.id, ChatMessageRole.USER, "What is Kotlin?"), - chatMessage(session.id, ChatMessageRole.ASSISTANT, "A JVM language."), - chatMessage(session.id, ChatMessageRole.USER, "Tell me more"), + conversationMessage(conversation.id, ConversationMessageRole.USER, "What is Kotlin?"), + conversationMessage(conversation.id, ConversationMessageRole.ASSISTANT, "A JVM language."), + conversationMessage(conversation.id, ConversationMessageRole.USER, "Tell me more"), ) - every { sessions.findById(session.id) } returns session - every { messages.findAllBySessionIdOrderedByTime(session.id) } returns history + every { conversations.findById(conversation.id) } returns conversation + every { messages.findAllByConversationIdOrderedByTime(conversation.id) } returns history every { commandBus.dispatch(any()) } just runs every { generation.generate(capture(promptSlot), any()) } returns "Answer" - service.stream(session.id, "Tell me more") + service.stream(conversation.id, "Tell me more") // History already ends with this exact user turn (persist-then-stream // ordering) so it must appear once, not twice. @@ -163,32 +163,32 @@ class ChatAnswerStreamServiceTest { @Test fun `error during generation emits error SSE event`() { - val session = session() - every { sessions.findById(session.id) } returns session - every { messages.findAllBySessionIdOrderedByTime(session.id) } returns emptyList() + val conversation = conversation() + every { conversations.findById(conversation.id) } returns conversation + every { messages.findAllByConversationIdOrderedByTime(conversation.id) } returns emptyList() every { generation.generate(any(), any()) } throws RuntimeException("backend unavailable") - val emitter = service.stream(session.id, "Hi") + val emitter = service.stream(conversation.id, "Hi") assertThat(emitter).isNotNull() } - private fun session( - id: ChatSessionId = ChatSessionId.random(), + private fun conversation( + id: ConversationId = ConversationId.random(), userId: UUID = UUID.randomUUID(), - ): ChatSession { + ): Conversation { val now = Instant.now() - return ChatSession(id, userId, "x", ChatSessionStatus.ACTIVE, ChatSessionKind.PLAIN, now, now) + return Conversation(id, userId, "x", ConversationStatus.ACTIVE, ConversationKind.PLAIN, now, now) } - private fun chatMessage( - sessionId: ChatSessionId, - role: ChatMessageRole, + private fun conversationMessage( + conversationId: ConversationId, + role: ConversationMessageRole, body: String, - ): ChatMessage = - ChatMessage( - id = ChatMessageId.random(), - sessionId = sessionId, + ): ConversationMessage = + ConversationMessage( + id = ConversationMessageId.random(), + conversationId = conversationId, role = role, body = body, createdAt = Instant.now(), diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommandHandlerTest.kt deleted file mode 100644 index f479b21..0000000 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendChatMessageCommandHandlerTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.jorisjonkers.personalstack.agents.application.command - -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessage -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.agents.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.agents.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant -import java.util.UUID - -class AppendChatMessageCommandHandlerTest { - private val sessions = mockk() - private val messages = mockk() - private val handler = AppendChatMessageCommandHandler(sessions, messages) - - private val session = - ChatSession( - id = ChatSessionId.random(), - userId = UUID.randomUUID(), - title = "x", - status = ChatSessionStatus.ACTIVE, - kind = ChatSessionKind.PLAIN, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `handle persists the message and bumps session updatedAt`() { - every { sessions.findById(session.id) } returns session - val saved = slot() - every { messages.save(capture(saved)) } answers { saved.captured } - every { sessions.save(any()) } answers { firstArg() } - - val mid = ChatMessageId.random() - handler.handle(AppendChatMessageCommand(mid, session.id, ChatMessageRole.USER, "Hello")) - - assertThat(saved.captured.id).isEqualTo(mid) - assertThat(saved.captured.role).isEqualTo(ChatMessageRole.USER) - assertThat(saved.captured.body).isEqualTo("Hello") - verify { sessions.save(any()) } - } - - @Test - fun `handle rejects a blank body`() { - every { sessions.findById(session.id) } returns session - assertThrows { - handler.handle(AppendChatMessageCommand(ChatMessageId.random(), session.id, ChatMessageRole.USER, " ")) - } - } - - @Test - fun `handle throws NotFound for an unknown session`() { - every { sessions.findById(any()) } returns null - assertThrows { - handler.handle( - AppendChatMessageCommand( - messageId = ChatMessageId.random(), - sessionId = ChatSessionId.random(), - role = ChatMessageRole.USER, - body = "hey", - ), - ) - } - } -} diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommandHandlerTest.kt new file mode 100644 index 0000000..21ecd8a --- /dev/null +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AppendConversationMessageCommandHandlerTest.kt @@ -0,0 +1,84 @@ +package com.jorisjonkers.personalstack.agents.application.command + +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessage +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationMessageRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import com.jorisjonkers.personalstack.common.exception.NotFoundException +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant +import java.util.UUID + +class AppendConversationMessageCommandHandlerTest { + private val conversations = mockk() + private val messages = mockk() + private val handler = AppendConversationMessageCommandHandler(conversations, messages) + + private val conversation = + Conversation( + id = ConversationId.random(), + userId = UUID.randomUUID(), + title = "x", + status = ConversationStatus.ACTIVE, + kind = ConversationKind.PLAIN, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + + @Test + fun `handle persists the message and bumps conversation updatedAt`() { + every { conversations.findById(conversation.id) } returns conversation + val saved = slot() + every { messages.save(capture(saved)) } answers { saved.captured } + every { conversations.save(any()) } answers { firstArg() } + + val mid = ConversationMessageId.random() + handler.handle(AppendConversationMessageCommand(mid, conversation.id, ConversationMessageRole.USER, "Hello")) + + assertThat(saved.captured.id).isEqualTo(mid) + assertThat(saved.captured.role).isEqualTo(ConversationMessageRole.USER) + assertThat(saved.captured.body).isEqualTo("Hello") + verify { conversations.save(any()) } + } + + @Test + fun `handle rejects a blank body`() { + every { conversations.findById(conversation.id) } returns conversation + assertThrows { + handler.handle( + AppendConversationMessageCommand( + ConversationMessageId.random(), + conversation.id, + ConversationMessageRole.USER, + " ", + ), + ) + } + } + + @Test + fun `handle throws NotFound for an unknown conversation`() { + every { conversations.findById(any()) } returns null + assertThrows { + handler.handle( + AppendConversationMessageCommand( + messageId = ConversationMessageId.random(), + conversationId = ConversationId.random(), + role = ConversationMessageRole.USER, + body = "hey", + ), + ) + } + } +} diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommandHandlerTest.kt deleted file mode 100644 index 7a0fa25..0000000 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveChatSessionCommandHandlerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.jorisjonkers.personalstack.agents.application.command - -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.exception.DomainException -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant -import java.util.UUID - -class ArchiveChatSessionCommandHandlerTest { - private val sessions = mockk() - private val handler = ArchiveChatSessionCommandHandler(sessions) - - private fun session(userId: UUID) = - ChatSession( - id = ChatSessionId.random(), - userId = userId, - title = "x", - status = ChatSessionStatus.ACTIVE, - kind = ChatSessionKind.PLAIN, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `handle archives the session when the owner asks`() { - val uid = UUID.randomUUID() - val s = session(uid) - every { sessions.findById(s.id) } returns s - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - handler.handle(ArchiveChatSessionCommand(s.id, uid)) - - assertThat(saved.captured.status).isEqualTo(ChatSessionStatus.ARCHIVED) - verify { sessions.save(any()) } - } - - @Test - fun `handle throws NotFound for an unknown session`() { - every { sessions.findById(any()) } returns null - assertThrows { - handler.handle(ArchiveChatSessionCommand(ChatSessionId.random(), UUID.randomUUID())) - } - } - - @Test - fun `handle rejects a non-owner`() { - val s = session(UUID.randomUUID()) - every { sessions.findById(s.id) } returns s - assertThrows { - handler.handle(ArchiveChatSessionCommand(s.id, UUID.randomUUID())) - } - } -} diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommandHandlerTest.kt new file mode 100644 index 0000000..09f4952 --- /dev/null +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/ArchiveConversationCommandHandlerTest.kt @@ -0,0 +1,65 @@ +package com.jorisjonkers.personalstack.agents.application.command + +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import com.jorisjonkers.personalstack.common.exception.DomainException +import com.jorisjonkers.personalstack.common.exception.NotFoundException +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant +import java.util.UUID + +class ArchiveConversationCommandHandlerTest { + private val conversations = mockk() + private val handler = ArchiveConversationCommandHandler(conversations) + + private fun conversation(userId: UUID) = + Conversation( + id = ConversationId.random(), + userId = userId, + title = "x", + status = ConversationStatus.ACTIVE, + kind = ConversationKind.PLAIN, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + + @Test + fun `handle archives the conversation when the owner asks`() { + val uid = UUID.randomUUID() + val c = conversation(uid) + every { conversations.findById(c.id) } returns c + val saved = slot() + every { conversations.save(capture(saved)) } answers { saved.captured } + + handler.handle(ArchiveConversationCommand(c.id, uid)) + + assertThat(saved.captured.status).isEqualTo(ConversationStatus.ARCHIVED) + verify { conversations.save(any()) } + } + + @Test + fun `handle throws NotFound for an unknown conversation`() { + every { conversations.findById(any()) } returns null + assertThrows { + handler.handle(ArchiveConversationCommand(ConversationId.random(), UUID.randomUUID())) + } + } + + @Test + fun `handle rejects a non-owner`() { + val c = conversation(UUID.randomUUID()) + every { conversations.findById(c.id) } returns c + assertThrows { + handler.handle(ArchiveConversationCommand(c.id, UUID.randomUUID())) + } + } +} diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommandHandlerTest.kt index 53acacd..3f65ad1 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateWorkspaceCommandHandlerTest.kt @@ -440,7 +440,7 @@ class CreateWorkspaceCommandHandlerTest { } @Test - fun `handle CHAT kind is rejected (chat lives in ChatSession)`() { + fun `handle CHAT kind is rejected (chat lives in Conversation)`() { assertThrows { handler.handle( CreateWorkspaceCommand( diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommandHandlerTest.kt deleted file mode 100644 index 73a13e4..0000000 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartChatSessionCommandHandlerTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.jorisjonkers.personalstack.agents.application.command - -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.agents.domain.port.ChatSessionRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.util.UUID - -class StartChatSessionCommandHandlerTest { - private val sessions = mockk() - private val handler = StartChatSessionCommandHandler(sessions) - - @Test - fun `handle persists an ACTIVE session with trimmed title`() { - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - val sid = ChatSessionId.random() - val uid = UUID.randomUUID() - handler.handle(StartChatSessionCommand(sid, uid, " My chat ")) - - assertThat(saved.captured.id).isEqualTo(sid) - assertThat(saved.captured.userId).isEqualTo(uid) - assertThat(saved.captured.title).isEqualTo("My chat") - assertThat(saved.captured.status).isEqualTo(ChatSessionStatus.ACTIVE) - } - - @Test - fun `handle treats a blank title as null`() { - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - handler.handle(StartChatSessionCommand(ChatSessionId.random(), UUID.randomUUID(), " ")) - - assertThat(saved.captured.title).isNull() - } - - @Test - fun `handle allows a null title`() { - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - handler.handle(StartChatSessionCommand(ChatSessionId.random(), UUID.randomUUID(), null)) - - assertThat(saved.captured.title).isNull() - } -} diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommandHandlerTest.kt new file mode 100644 index 0000000..3ef7f7a --- /dev/null +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/StartConversationCommandHandlerTest.kt @@ -0,0 +1,52 @@ +package com.jorisjonkers.personalstack.agents.application.command + +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +class StartConversationCommandHandlerTest { + private val conversations = mockk() + private val handler = StartConversationCommandHandler(conversations) + + @Test + fun `handle persists an ACTIVE conversation with trimmed title`() { + val saved = slot() + every { conversations.save(capture(saved)) } answers { saved.captured } + + val cid = ConversationId.random() + val uid = UUID.randomUUID() + handler.handle(StartConversationCommand(cid, uid, " My chat ")) + + assertThat(saved.captured.id).isEqualTo(cid) + assertThat(saved.captured.userId).isEqualTo(uid) + assertThat(saved.captured.title).isEqualTo("My chat") + assertThat(saved.captured.status).isEqualTo(ConversationStatus.ACTIVE) + } + + @Test + fun `handle treats a blank title as null`() { + val saved = slot() + every { conversations.save(capture(saved)) } answers { saved.captured } + + handler.handle(StartConversationCommand(ConversationId.random(), UUID.randomUUID(), " ")) + + assertThat(saved.captured.title).isNull() + } + + @Test + fun `handle allows a null title`() { + val saved = slot() + every { conversations.save(capture(saved)) } answers { saved.captured } + + handler.handle(StartConversationCommand(ConversationId.random(), UUID.randomUUID(), null)) + + assertThat(saved.captured.title).isNull() + } +} diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/query/ConversationQueryServiceTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/query/ConversationQueryServiceTest.kt new file mode 100644 index 0000000..ebb2f9d --- /dev/null +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/query/ConversationQueryServiceTest.kt @@ -0,0 +1,91 @@ +package com.jorisjonkers.personalstack.agents.application.query + +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.agents.domain.port.ConversationMessageRepository +import com.jorisjonkers.personalstack.agents.domain.port.ConversationRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.UUID + +class ConversationQueryServiceTest { + private val conversations = mockk() + private val messages = mockk() + private val service = ConversationQueryService(conversations, messages) + + private fun conversation( + id: ConversationId = ConversationId.random(), + userId: UUID = UUID.randomUUID(), + ): Conversation { + val now = Instant.now() + return Conversation(id, userId, "x", ConversationStatus.ACTIVE, ConversationKind.PLAIN, now, now) + } + + @Test + fun `get returns the detail for the owner`() { + val c = conversation() + every { conversations.findById(c.id) } returns c + every { messages.findAllByConversationIdOrderedByTime(c.id) } returns emptyList() + + val result = service.get(c.id, c.userId) + + assertThat(result).isEqualTo(ConversationQueryService.ConversationDetail(c, emptyList())) + } + + @Test + fun `get returns nothing for another user's conversation`() { + val c = conversation() + val otherUser = UUID.randomUUID() + every { conversations.findById(c.id) } returns c + + val result = service.get(c.id, otherUser) + + assertThat(result).isNull() + verify(exactly = 0) { messages.findAllByConversationIdOrderedByTime(any()) } + } + + @Test + fun `get returns nothing for an unknown id`() { + val unknownId = ConversationId.random() + val requester = UUID.randomUUID() + every { conversations.findById(unknownId) } returns null + + val result = service.get(unknownId, requester) + + assertThat(result).isNull() + verify(exactly = 0) { messages.findAllByConversationIdOrderedByTime(any()) } + } + + @Test + fun `another user's id and an unknown id are indistinguishable at the seam`() { + val c = conversation() + val otherUser = UUID.randomUUID() + val unknownId = ConversationId.random() + every { conversations.findById(c.id) } returns c + every { conversations.findById(unknownId) } returns null + + val forAnotherUsersConversation = service.get(c.id, otherUser) + val forAnUnknownId = service.get(unknownId, otherUser) + + assertThat(forAnotherUsersConversation).isNull() + assertThat(forAnUnknownId).isNull() + assertThat(forAnotherUsersConversation).isEqualTo(forAnUnknownId) + } + + @Test + fun `list is unaffected and remains scoped to the requesting user`() { + val userId = UUID.randomUUID() + val owned = conversation(userId = userId) + every { conversations.findAllByUserId(userId) } returns listOf(owned) + + val result = service.list(userId) + + assertThat(result).containsExactly(owned) + } +} diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/archunit/ArchitectureTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/archunit/ArchitectureTest.kt index 0eb2c82..a8f3eb9 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/archunit/ArchitectureTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/archunit/ArchitectureTest.kt @@ -5,8 +5,13 @@ import com.tngtech.archunit.core.importer.ClassFileImporter import com.tngtech.archunit.core.importer.ImportOption import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestHeader @TestInstance(TestInstance.Lifecycle.PER_CLASS) class ArchitectureTest { @@ -96,15 +101,14 @@ class ArchitectureTest { val redesignDomainTypes = com.tngtech.archunit.base.DescribedPredicate .describe( - "redesign domain types (Repository*, ChatSession*, ChatMessage*, WorkspaceKind)", + "redesign domain types (Repository*, Conversation*, WorkspaceKind)", ) { javaClass -> val inDomainModel = javaClass.packageName.endsWith(".domain.model") val simpleName = javaClass.simpleName inDomainModel && ( simpleName.startsWith("Repository") || - simpleName.startsWith("ChatSession") || - simpleName.startsWith("ChatMessage") || + simpleName.startsWith("Conversation") || simpleName == "WorkspaceKind" ) } @@ -118,7 +122,7 @@ class ArchitectureTest { "org.jooq..", "com.fasterxml.jackson..", ).because( - "redesign domain entities (Repository / ChatSession / ChatMessage / " + + "redesign domain entities (Repository / Conversation / " + "WorkspaceKind) must remain framework-free (ADR-006)", ).check(importedClasses) } @@ -135,4 +139,35 @@ class ArchitectureTest { .because("query services must follow *QueryService naming convention") .check(importedClasses) } + + @Test + fun `every Conversation handler requires the caller identity header`() { + val httpMappingAnnotations = listOf(GetMapping::class.java, PostMapping::class.java, DeleteMapping::class.java) + val handlerMethods = + importedClasses + .filter { it.simpleName == "ConversationController" || it.simpleName == "ChatSessionController" } + .flatMap { it.methods } + .filter { method -> httpMappingAnnotations.any(method::isAnnotatedWith) } + + // The trap this guards against: a rename that leaves a name-based + // predicate matching zero classes, so the rule below would pass + // having checked nothing (see the CommandHandler-naming rule above + // and #80). Fail loudly instead of vacuously. + assertThat(handlerMethods) + .describedAs("expected to find Conversation/ChatSession handler methods to check") + .isNotEmpty() + + val missingIdentityHeader = + handlerMethods.filterNot { method -> + method.reflect().parameters.any { parameter -> + parameter.getAnnotation(RequestHeader::class.java)?.value == "X-User-Id" + } + } + + assertThat(missingIdentityHeader) + .describedAs( + "handlers missing the X-User-Id header: %s", + missingIdentityHeader.map { it.fullName }, + ).isEmpty() + } } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ChatSessionControllerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ChatSessionControllerTest.kt index 295f616..97b34d2 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ChatSessionControllerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ChatSessionControllerTest.kt @@ -2,11 +2,11 @@ package com.jorisjonkers.personalstack.agents.infrastructure.web import com.fasterxml.jackson.databind.ObjectMapper import com.jorisjonkers.personalstack.agents.application.chat.ChatAnswerStreamService -import com.jorisjonkers.personalstack.agents.application.query.ChatSessionQueryService -import com.jorisjonkers.personalstack.agents.domain.model.ChatSession -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.agents.domain.model.ChatSessionStatus +import com.jorisjonkers.personalstack.agents.application.query.ConversationQueryService +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus import com.jorisjonkers.personalstack.common.command.CommandBus import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler import io.mockk.every @@ -29,9 +29,14 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import java.time.Instant import java.util.UUID +// This is the deprecated /api/v1/chat-sessions alias -- see +// ConversationControllerTest for the canonical /api/v1/conversations +// coverage. Kept to prove the alias still serves the pre-rename shapes +// byte-for-byte, and inherits the same ownership check (see #80). +@Suppress("DEPRECATION") class ChatSessionControllerTest { private val commandBus = mockk(relaxed = true) - private val query = mockk() + private val query = mockk() private val chatAnswerStream = mockk() private val objectMapper = ObjectMapper() private lateinit var mockMvc: MockMvc @@ -46,22 +51,22 @@ class ChatSessionControllerTest { .build() } - private fun session( - id: ChatSessionId = ChatSessionId.random(), + private fun conversation( + id: ConversationId = ConversationId.random(), userId: UUID = UUID.randomUUID(), - ): ChatSession { + ): Conversation { val now = Instant.now() - return ChatSession(id, userId, "x", ChatSessionStatus.ACTIVE, ChatSessionKind.PLAIN, now, now) + return Conversation(id, userId, "x", ConversationStatus.ACTIVE, ConversationKind.PLAIN, now, now) } @Test fun `POST creates session and returns 201`() { - val s = session() - every { query.get(any()) } returns ChatSessionQueryService.ChatSessionDetail(s, emptyList()) + val c = conversation() + every { query.get(any(), c.userId) } returns ConversationQueryService.ConversationDetail(c, emptyList()) mockMvc .perform( post("/api/v1/chat-sessions") - .header("X-User-Id", s.userId.toString()) + .header("X-User-Id", c.userId.toString()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(mapOf("title" to "x", "kind" to "PLAIN"))), ).andExpect(status().isCreated) @@ -73,7 +78,7 @@ class ChatSessionControllerTest { @Test fun `GET list returns sessions for user`() { val uid = UUID.randomUUID() - every { query.list(uid) } returns listOf(session(userId = uid)) + every { query.list(uid) } returns listOf(conversation(userId = uid)) mockMvc .perform(get("/api/v1/chat-sessions").header("X-User-Id", uid.toString())) .andExpect(status().isOk) @@ -81,37 +86,73 @@ class ChatSessionControllerTest { } @Test - fun `GET by id returns envelope`() { - val s = session() - every { query.get(s.id) } returns ChatSessionQueryService.ChatSessionDetail(s, emptyList()) + fun `GET by id returns envelope for the owner`() { + val c = conversation() + every { query.get(c.id, c.userId) } returns ConversationQueryService.ConversationDetail(c, emptyList()) mockMvc - .perform(get("/api/v1/chat-sessions/${s.id.value}")) + .perform(get("/api/v1/chat-sessions/${c.id.value}").header("X-User-Id", c.userId.toString())) .andExpect(status().isOk) - .andExpect(jsonPath("$.session.id").value(s.id.value.toString())) + .andExpect(jsonPath("$.session.id").value(c.id.value.toString())) .andExpect(jsonPath("$.messages").isArray) } @Test - fun `GET by id with unknown returns 404`() { - every { query.get(any()) } returns null + fun `GET by id with unknown session returns 404`() { + val requester = UUID.randomUUID() + every { query.get(any(), requester) } returns null mockMvc - .perform(get("/api/v1/chat-sessions/${UUID.randomUUID()}")) + .perform(get("/api/v1/chat-sessions/${UUID.randomUUID()}").header("X-User-Id", requester.toString())) .andExpect(status().isNotFound) } @Test - fun `POST messages dispatches the append command`() { - val s = session() + fun `GET by id owned by another user returns 404, indistinguishable from unknown`() { + val c = conversation() + val otherUser = UUID.randomUUID() + every { query.get(c.id, otherUser) } returns null + mockMvc + .perform(get("/api/v1/chat-sessions/${c.id.value}").header("X-User-Id", otherUser.toString())) + .andExpect(status().isNotFound) + } + + // The header is declared optional so the alias keeps the contract it + // published before #80. That is only safe while its absence refuses + // exactly like a wrong identity does. + @Test + fun `GET by id without the identity header returns 404, never the conversation`() { + val c = conversation() + mockMvc + .perform(get("/api/v1/chat-sessions/${c.id.value}")) + .andExpect(status().isNotFound) + verify(exactly = 0) { query.get(any(), any()) } + } + + @Test + fun `POST messages without the identity header returns 404 and appends no Turn`() { + val c = conversation() + mockMvc + .perform( + post("/api/v1/chat-sessions/${c.id.value}/messages") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hi", "role" to "USER"))), + ).andExpect(status().isNotFound) + verify(exactly = 0) { commandBus.dispatch(any()) } + } + + @Test + fun `POST messages dispatches the append command for the owner`() { + val c = conversation() // The controller looks up the just-appended message by id after // dispatch. Return a detail with no matching message so the // controller takes the error path; the test asserts the // dispatch happened either way. - every { query.get(any()) } returns - ChatSessionQueryService.ChatSessionDetail(s, emptyList()) + every { query.get(c.id, c.userId) } returns + ConversationQueryService.ConversationDetail(c, emptyList()) try { mockMvc .perform( - post("/api/v1/chat-sessions/${s.id.value}/messages") + post("/api/v1/chat-sessions/${c.id.value}/messages") + .header("X-User-Id", c.userId.toString()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), ) @@ -124,13 +165,44 @@ class ChatSessionControllerTest { } @Test - fun `POST stream messages returns SSE response headers`() { - val s = session() - every { chatAnswerStream.stream(s.id, "hello") } returns SseEmitter() + fun `POST messages with unknown session returns 404 and appends no Turn`() { + val requester = UUID.randomUUID() + every { query.get(any(), requester) } returns null + mockMvc + .perform( + post("/api/v1/chat-sessions/${UUID.randomUUID()}/messages") + .header("X-User-Id", requester.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ).andExpect(status().isNotFound) + verify(exactly = 0) { commandBus.dispatch(any()) } + } + + @Test + fun `POST messages owned by another user returns 404 and appends no Turn`() { + val c = conversation() + val otherUser = UUID.randomUUID() + every { query.get(c.id, otherUser) } returns null + mockMvc + .perform( + post("/api/v1/chat-sessions/${c.id.value}/messages") + .header("X-User-Id", otherUser.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ).andExpect(status().isNotFound) + verify(exactly = 0) { commandBus.dispatch(any()) } + } + + @Test + fun `POST stream messages returns SSE response headers for the owner`() { + val c = conversation() + every { query.get(c.id, c.userId) } returns ConversationQueryService.ConversationDetail(c, emptyList()) + every { chatAnswerStream.stream(c.id, "hello") } returns SseEmitter() mockMvc .perform( - post("/api/v1/chat-sessions/${s.id.value}/messages/stream") + post("/api/v1/chat-sessions/${c.id.value}/messages/stream") + .header("X-User-Id", c.userId.toString()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), ).andExpect(status().isOk) @@ -138,13 +210,17 @@ class ChatSessionControllerTest { .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)) .andExpect(header().string("X-Accel-Buffering", "no")) .andExpect(header().string("Cache-Control", "no-cache")) + + verify { chatAnswerStream.stream(c.id, "hello") } } @Test fun `POST stream messages with blank body returns validation error`() { + val requester = UUID.randomUUID() mockMvc .perform( post("/api/v1/chat-sessions/${UUID.randomUUID()}/messages/stream") + .header("X-User-Id", requester.toString()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(mapOf("body" to "", "role" to "USER"))), ).andExpect(status().isUnprocessableContent) @@ -155,19 +231,38 @@ class ChatSessionControllerTest { mockMvc .perform( post("/api/v1/chat-sessions/${UUID.randomUUID()}/messages/stream") + .header("X-User-Id", UUID.randomUUID().toString()) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) } @Test - fun `POST messages with unknown session returns 404`() { - every { query.get(any()) } returns null + fun `POST stream messages with unknown session returns 404 and never starts a stream`() { + val requester = UUID.randomUUID() + every { query.get(any(), requester) } returns null mockMvc .perform( - post("/api/v1/chat-sessions/${UUID.randomUUID()}/messages") + post("/api/v1/chat-sessions/${UUID.randomUUID()}/messages/stream") + .header("X-User-Id", requester.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ).andExpect(status().isNotFound) + verify(exactly = 0) { chatAnswerStream.stream(any(), any()) } + } + + @Test + fun `POST stream messages owned by another user returns 404 and never starts a stream`() { + val c = conversation() + val otherUser = UUID.randomUUID() + every { query.get(c.id, otherUser) } returns null + mockMvc + .perform( + post("/api/v1/chat-sessions/${c.id.value}/messages/stream") + .header("X-User-Id", otherUser.toString()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), ).andExpect(status().isNotFound) + verify(exactly = 0) { chatAnswerStream.stream(any(), any()) } } @Test diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ConversationControllerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ConversationControllerTest.kt new file mode 100644 index 0000000..21f7dae --- /dev/null +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/ConversationControllerTest.kt @@ -0,0 +1,271 @@ +package com.jorisjonkers.personalstack.agents.infrastructure.web + +import com.fasterxml.jackson.databind.ObjectMapper +import com.jorisjonkers.personalstack.agents.application.chat.ChatAnswerStreamService +import com.jorisjonkers.personalstack.agents.application.command.AppendConversationMessageCommand +import com.jorisjonkers.personalstack.agents.application.query.ConversationQueryService +import com.jorisjonkers.personalstack.agents.domain.model.Conversation +import com.jorisjonkers.personalstack.agents.domain.model.ConversationId +import com.jorisjonkers.personalstack.agents.domain.model.ConversationKind +import com.jorisjonkers.personalstack.agents.domain.model.ConversationMessageRole +import com.jorisjonkers.personalstack.agents.domain.model.ConversationStatus +import com.jorisjonkers.personalstack.common.command.Command +import com.jorisjonkers.personalstack.common.command.CommandBus +import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.request +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.time.Instant +import java.util.UUID + +class ConversationControllerTest { + private val commandBus = mockk(relaxed = true) + private val query = mockk() + private val chatAnswerStream = mockk() + private val objectMapper = ObjectMapper() + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setUp() { + val controller = ConversationController(commandBus, query, chatAnswerStream) + mockMvc = + MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(GlobalExceptionHandler()) + .build() + } + + private fun conversation( + id: ConversationId = ConversationId.random(), + userId: UUID = UUID.randomUUID(), + ): Conversation { + val now = Instant.now() + return Conversation(id, userId, "x", ConversationStatus.ACTIVE, ConversationKind.PLAIN, now, now) + } + + @Test + fun `POST creates conversation and returns 201`() { + val c = conversation() + every { query.get(any(), c.userId) } returns ConversationQueryService.ConversationDetail(c, emptyList()) + mockMvc + .perform( + post("/api/v1/conversations") + .header("X-User-Id", c.userId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("title" to "x", "kind" to "PLAIN"))), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.kind").value("PLAIN")) + verify { commandBus.dispatch(any()) } + } + + @Test + fun `GET list returns conversations for user`() { + val uid = UUID.randomUUID() + every { query.list(uid) } returns listOf(conversation(userId = uid)) + mockMvc + .perform(get("/api/v1/conversations").header("X-User-Id", uid.toString())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(1)) + } + + @Test + fun `GET by id returns a flat ConversationDetailResponse for the owner`() { + val c = conversation() + every { query.get(c.id, c.userId) } returns ConversationQueryService.ConversationDetail(c, emptyList()) + mockMvc + .perform(get("/api/v1/conversations/${c.id.value}").header("X-User-Id", c.userId.toString())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(c.id.value.toString())) + .andExpect(jsonPath("$.title").value("x")) + .andExpect(jsonPath("$.messages").isArray) + } + + @Test + fun `GET messages returns the conversation's messages for the owner`() { + val c = conversation() + every { query.get(c.id, c.userId) } returns ConversationQueryService.ConversationDetail(c, emptyList()) + mockMvc + .perform(get("/api/v1/conversations/${c.id.value}/messages").header("X-User-Id", c.userId.toString())) + .andExpect(status().isOk) + .andExpect(jsonPath("$").isArray) + } + + @Test + fun `GET messages owned by another user returns 404`() { + val c = conversation() + val otherUser = UUID.randomUUID() + every { query.get(c.id, otherUser) } returns null + mockMvc + .perform(get("/api/v1/conversations/${c.id.value}/messages").header("X-User-Id", otherUser.toString())) + .andExpect(status().isNotFound) + } + + @Test + fun `GET by id with unknown conversation returns 404`() { + val requester = UUID.randomUUID() + every { query.get(any(), requester) } returns null + mockMvc + .perform(get("/api/v1/conversations/${UUID.randomUUID()}").header("X-User-Id", requester.toString())) + .andExpect(status().isNotFound) + } + + @Test + fun `GET by id owned by another user returns 404, indistinguishable from unknown`() { + val c = conversation() + val otherUser = UUID.randomUUID() + every { query.get(c.id, otherUser) } returns null + mockMvc + .perform(get("/api/v1/conversations/${c.id.value}").header("X-User-Id", otherUser.toString())) + .andExpect(status().isNotFound) + } + + @Test + fun `POST messages dispatches the append command for the owner`() { + val c = conversation() + every { query.get(c.id, c.userId) } returns + ConversationQueryService.ConversationDetail(c, emptyList()) + try { + mockMvc + .perform( + post("/api/v1/conversations/${c.id.value}/messages") + .header("X-User-Id", c.userId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ) + } catch (_: Throwable) { + // The controller's error("message not visible…") surfaces + // as an unhandled exception in MockMvc; verify the + // dispatch happened regardless. + } + verify { commandBus.dispatch(any()) } + } + + @Test + fun `POST messages accepts the legacy content field and defaults role to USER`() { + val c = conversation() + every { query.get(c.id, c.userId) } returns + ConversationQueryService.ConversationDetail(c, emptyList()) + val dispatched = slot() + every { commandBus.dispatch(capture(dispatched)) } returns Unit + try { + mockMvc + .perform( + post("/api/v1/conversations/${c.id.value}/messages") + .header("X-User-Id", c.userId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("content" to "legacy hello"))), + ) + } catch (_: Throwable) { + // Same unhandled-exception caveat as above. + } + val command = dispatched.captured as AppendConversationMessageCommand + assertThat(command.body).isEqualTo("legacy hello") + assertThat(command.role).isEqualTo(ConversationMessageRole.USER) + } + + @Test + fun `POST messages with unknown conversation returns 404 and appends no Turn`() { + val requester = UUID.randomUUID() + every { query.get(any(), requester) } returns null + mockMvc + .perform( + post("/api/v1/conversations/${UUID.randomUUID()}/messages") + .header("X-User-Id", requester.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ).andExpect(status().isNotFound) + verify(exactly = 0) { commandBus.dispatch(any()) } + } + + @Test + fun `POST messages owned by another user returns 404 and appends no Turn`() { + val c = conversation() + val otherUser = UUID.randomUUID() + every { query.get(c.id, otherUser) } returns null + mockMvc + .perform( + post("/api/v1/conversations/${c.id.value}/messages") + .header("X-User-Id", otherUser.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ).andExpect(status().isNotFound) + verify(exactly = 0) { commandBus.dispatch(any()) } + } + + @Test + fun `POST messages stream starts for the owner`() { + val c = conversation() + every { query.get(c.id, c.userId) } returns ConversationQueryService.ConversationDetail(c, emptyList()) + every { chatAnswerStream.stream(c.id, "hello") } returns SseEmitter() + + mockMvc + .perform( + post("/api/v1/conversations/${c.id.value}/messages/stream") + .header("X-User-Id", c.userId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ).andExpect(status().isOk) + .andExpect(request().asyncStarted()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)) + .andExpect(header().string("Cache-Control", "no-cache")) + .andExpect(header().string("X-Accel-Buffering", "no")) + + verify { chatAnswerStream.stream(c.id, "hello") } + } + + @Test + fun `POST messages stream with unknown conversation returns 404 and never starts a stream`() { + val requester = UUID.randomUUID() + every { query.get(any(), requester) } returns null + mockMvc + .perform( + post("/api/v1/conversations/${UUID.randomUUID()}/messages/stream") + .header("X-User-Id", requester.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ).andExpect(status().isNotFound) + verify(exactly = 0) { chatAnswerStream.stream(any(), any()) } + } + + @Test + fun `POST messages stream owned by another user returns 404 and never starts a stream`() { + val c = conversation() + val otherUser = UUID.randomUUID() + every { query.get(c.id, otherUser) } returns null + mockMvc + .perform( + post("/api/v1/conversations/${c.id.value}/messages/stream") + .header("X-User-Id", otherUser.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), + ).andExpect(status().isNotFound) + verify(exactly = 0) { chatAnswerStream.stream(any(), any()) } + } + + @Test + fun `DELETE archives conversation and returns 204`() { + mockMvc + .perform( + delete("/api/v1/conversations/${UUID.randomUUID()}") + .header("X-User-Id", UUID.randomUUID().toString()), + ).andExpect(status().isNoContent) + verify { commandBus.dispatch(any()) } + } +} diff --git a/client-spec/openapi/agents-api-client.json b/client-spec/openapi/agents-api-client.json index f879361..25af4f9 100644 --- a/client-spec/openapi/agents-api-client.json +++ b/client-spec/openapi/agents-api-client.json @@ -689,10 +689,152 @@ } } }, + "/api/v1/conversations" : { + "get" : { + "tags" : [ "conversation-controller" ], + "operationId" : "list_3", + "parameters" : [ { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConversationResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "conversation-controller" ], + "operationId" : "create_3", + "parameters" : [ { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/StartConversationRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConversationResponse" + } + } + } + } + } + } + }, + "/api/v1/conversations/{id}/messages" : { + "get" : { + "tags" : [ "conversation-controller" ], + "operationId" : "listMessages", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConversationMessageResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "conversation-controller" ], + "operationId" : "appendMessage", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/AppendConversationMessageRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConversationMessageResponse" + } + } + } + } + } + } + }, "/api/v1/chat-sessions" : { "get" : { "tags" : [ "chat-session-controller" ], - "operationId" : "list_3", + "operationId" : "list_4", "parameters" : [ { "name" : "X-User-Id", "in" : "header", @@ -715,11 +857,12 @@ } } } - } + }, + "deprecated" : true }, "post" : { "tags" : [ "chat-session-controller" ], - "operationId" : "create_3", + "operationId" : "create_4", "parameters" : [ { "name" : "X-User-Id", "in" : "header", @@ -749,13 +892,14 @@ } } } - } + }, + "deprecated" : true } }, "/api/v1/chat-sessions/{id}/messages" : { "post" : { "tags" : [ "chat-session-controller" ], - "operationId" : "appendMessage", + "operationId" : "appendMessage_1", "parameters" : [ { "name" : "id", "in" : "path", @@ -764,6 +908,13 @@ "type" : "string", "format" : "uuid" } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } } ], "requestBody" : { "content" : { @@ -786,7 +937,8 @@ } } } - } + }, + "deprecated" : true } }, "/api/v1/workspaces/{workspaceId}/setup" : { @@ -1275,10 +1427,70 @@ } } }, + "/api/v1/conversations/{id}" : { + "get" : { + "tags" : [ "conversation-controller" ], + "operationId" : "get_3", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConversationDetailResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "conversation-controller" ], + "operationId" : "archive", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "204" : { + "description" : "No Content", + "content" : { } + } + } + } + }, "/api/v1/chat-sessions/{id}" : { "get" : { "tags" : [ "chat-session-controller" ], - "operationId" : "get_3", + "operationId" : "get_4", "parameters" : [ { "name" : "id", "in" : "path", @@ -1287,6 +1499,13 @@ "type" : "string", "format" : "uuid" } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } } ], "responses" : { "200" : { @@ -1300,11 +1519,12 @@ } } } - } + }, + "deprecated" : true }, "delete" : { "tags" : [ "chat-session-controller" ], - "operationId" : "archive", + "operationId" : "archive_1", "parameters" : [ { "name" : "id", "in" : "path", @@ -1326,7 +1546,8 @@ "description" : "OK", "content" : { } } - } + }, + "deprecated" : true } }, "/api/v1/agent-setups" : { @@ -2093,6 +2314,93 @@ }, "required" : [ "url" ] }, + "StartConversationRequest" : { + "type" : "object", + "properties" : { + "title" : { + "type" : [ "string", "null" ], + "maxLength" : 120, + "minLength" : 0 + }, + "kind" : { + "type" : [ "string", "null" ], + "enum" : [ "PLAIN", "KNOWLEDGE" ] + } + } + }, + "ConversationResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid" + }, + "userId" : { + "type" : "string", + "format" : "uuid" + }, + "title" : { + "type" : [ "string", "null" ] + }, + "status" : { + "type" : "string" + }, + "kind" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + }, + "required" : [ "createdAt", "id", "kind", "status", "updatedAt", "userId" ] + }, + "AppendConversationMessageRequest" : { + "type" : "object", + "properties" : { + "body" : { + "type" : [ "string", "null" ] + }, + "content" : { + "type" : [ "string", "null" ] + }, + "role" : { + "type" : [ "string", "null" ], + "enum" : [ "USER", "ASSISTANT", "SYSTEM" ] + } + } + }, + "ConversationMessageResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid" + }, + "conversationId" : { + "type" : "string", + "format" : "uuid" + }, + "role" : { + "type" : "string" + }, + "body" : { + "type" : "string" + }, + "content" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + }, + "required" : [ "body", "content", "conversationId", "createdAt", "id", "role" ] + }, "StartChatSessionRequest" : { "type" : "object", "properties" : { @@ -2762,6 +3070,43 @@ }, "required" : [ "exists", "state" ] }, + "ConversationDetailResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid" + }, + "userId" : { + "type" : "string", + "format" : "uuid" + }, + "title" : { + "type" : [ "string", "null" ] + }, + "status" : { + "type" : "string" + }, + "kind" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + }, + "messages" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConversationMessageResponse" + } + } + }, + "required" : [ "createdAt", "id", "kind", "messages", "status", "updatedAt", "userId" ] + }, "AgentSetupCatalogResponse" : { "type" : "object", "properties" : { diff --git a/client-spec/openapi/agents-api.json b/client-spec/openapi/agents-api.json index 830ed20..2102fd7 100644 --- a/client-spec/openapi/agents-api.json +++ b/client-spec/openapi/agents-api.json @@ -689,10 +689,152 @@ } } }, + "/api/v1/conversations" : { + "get" : { + "tags" : [ "conversation-controller" ], + "operationId" : "list_3", + "parameters" : [ { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConversationResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "conversation-controller" ], + "operationId" : "create_3", + "parameters" : [ { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/StartConversationRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConversationResponse" + } + } + } + } + } + } + }, + "/api/v1/conversations/{id}/messages" : { + "get" : { + "tags" : [ "conversation-controller" ], + "operationId" : "listMessages", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConversationMessageResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "conversation-controller" ], + "operationId" : "appendMessage", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/AppendConversationMessageRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConversationMessageResponse" + } + } + } + } + } + } + }, "/api/v1/chat-sessions" : { "get" : { "tags" : [ "chat-session-controller" ], - "operationId" : "list_3", + "operationId" : "list_4", "parameters" : [ { "name" : "X-User-Id", "in" : "header", @@ -715,11 +857,12 @@ } } } - } + }, + "deprecated" : true }, "post" : { "tags" : [ "chat-session-controller" ], - "operationId" : "create_3", + "operationId" : "create_4", "parameters" : [ { "name" : "X-User-Id", "in" : "header", @@ -749,13 +892,14 @@ } } } - } + }, + "deprecated" : true } }, "/api/v1/chat-sessions/{id}/messages" : { "post" : { "tags" : [ "chat-session-controller" ], - "operationId" : "appendMessage", + "operationId" : "appendMessage_1", "parameters" : [ { "name" : "id", "in" : "path", @@ -764,6 +908,13 @@ "type" : "string", "format" : "uuid" } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } } ], "requestBody" : { "content" : { @@ -786,7 +937,8 @@ } } } - } + }, + "deprecated" : true } }, "/api/v1/workspaces/{workspaceId}/setup" : { @@ -1275,10 +1427,70 @@ } } }, + "/api/v1/conversations/{id}" : { + "get" : { + "tags" : [ "conversation-controller" ], + "operationId" : "get_3", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConversationDetailResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "conversation-controller" ], + "operationId" : "archive", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "204" : { + "description" : "No Content", + "content" : { } + } + } + } + }, "/api/v1/chat-sessions/{id}" : { "get" : { "tags" : [ "chat-session-controller" ], - "operationId" : "get_3", + "operationId" : "get_4", "parameters" : [ { "name" : "id", "in" : "path", @@ -1287,6 +1499,13 @@ "type" : "string", "format" : "uuid" } + }, { + "name" : "X-User-Id", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } } ], "responses" : { "200" : { @@ -1300,11 +1519,12 @@ } } } - } + }, + "deprecated" : true }, "delete" : { "tags" : [ "chat-session-controller" ], - "operationId" : "archive", + "operationId" : "archive_1", "parameters" : [ { "name" : "id", "in" : "path", @@ -1326,7 +1546,8 @@ "description" : "OK", "content" : { } } - } + }, + "deprecated" : true } }, "/api/v1/agent-setups" : { @@ -2093,6 +2314,93 @@ }, "required" : [ "url" ] }, + "StartConversationRequest" : { + "type" : "object", + "properties" : { + "title" : { + "type" : [ "string", "null" ], + "maxLength" : 120, + "minLength" : 0 + }, + "kind" : { + "type" : [ "string", "null" ], + "enum" : [ "PLAIN", "KNOWLEDGE" ] + } + } + }, + "ConversationResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid" + }, + "userId" : { + "type" : "string", + "format" : "uuid" + }, + "title" : { + "type" : [ "string", "null" ] + }, + "status" : { + "type" : "string" + }, + "kind" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + }, + "required" : [ "createdAt", "id", "kind", "status", "updatedAt", "userId" ] + }, + "AppendConversationMessageRequest" : { + "type" : "object", + "properties" : { + "body" : { + "type" : [ "string", "null" ] + }, + "content" : { + "type" : [ "string", "null" ] + }, + "role" : { + "type" : [ "string", "null" ], + "enum" : [ "USER", "ASSISTANT", "SYSTEM" ] + } + } + }, + "ConversationMessageResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid" + }, + "conversationId" : { + "type" : "string", + "format" : "uuid" + }, + "role" : { + "type" : "string" + }, + "body" : { + "type" : "string" + }, + "content" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + }, + "required" : [ "body", "content", "conversationId", "createdAt", "id", "role" ] + }, "StartChatSessionRequest" : { "type" : "object", "properties" : { @@ -2762,6 +3070,43 @@ }, "required" : [ "exists", "state" ] }, + "ConversationDetailResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid" + }, + "userId" : { + "type" : "string", + "format" : "uuid" + }, + "title" : { + "type" : [ "string", "null" ] + }, + "status" : { + "type" : "string" + }, + "kind" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + }, + "messages" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConversationMessageResponse" + } + } + }, + "required" : [ "createdAt", "id", "kind", "messages", "status", "updatedAt", "userId" ] + }, "AgentSetupCatalogResponse" : { "type" : "object", "properties" : {