From b3aec5b92da08190c69d8bf7eb66f45360de8afb Mon Sep 17 00:00:00 2001 From: Garfie Date: Thu, 13 Aug 2026 00:40:33 -0500 Subject: [PATCH] fix(prompts): match the wire shapes the prompt version routes use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a prompt's body was impossible against any server, and promoting a version reported failure on success. `POST /api/prompts/groups/{id}/prompts` nests the version under `prompt` in both directions. The client sent it flat, so the route read the text where it expected an object and answered 400 on every body edit; its response is `{ prompt }` rather than a bare prompt, which would have failed to decode next. `savePrompt` answers `{ message }` with HTTP 200 when the write fails, so the wrapper's field is non-nullable and that body reports the failure it is. `PATCH /api/prompts/{id}/tags/production` promotes `req.params.promptId` and never reads a body, and answers `{ message }`, not the prompt. Decoding it as a prompt threw, which turned an accepted promotion into a `Result.Error` and with it skipped the repository's revision bump — so the composer's `/` picker went on offering the superseded body until the app restarted. It now sends no body, like the web client: a body naming a different prompt was silently ignored while reading as though it chose which version went live. Adding a version also does not make it live. A body edit now promotes the version it just wrote and reports the save on both calls, so the edit reaches the library row, the picker and the editor rather than being stored where nothing reads it. Web promotes on save the same way (`alwaysMakeProd`). `PromptsApiWireShapeTest` pins live captures of both bodies, since nothing above `:core:network` can see a JSON key. --- .../PromptRepositoryRevisionTest.kt | 13 +- .../core/data/repository/PromptRepository.kt | 11 +- .../data/repository/PromptRepositoryImpl.kt | 6 +- .../model/request/AddPromptToGroupRequest.kt | 10 +- .../model/request/UpdatePromptTagRequest.kt | 8 -- .../response/AddPromptToGroupResponse.kt | 17 +++ .../network/api/PromptsApiWireShapeTest.kt | 117 ++++++++++++++++++ .../librechat/core/network/api/PromptsApi.kt | 24 ++-- feature/chat/CLAUDE.md | 20 ++- .../prompts/PromptEditorSaveOutcomeTest.kt | 66 +++++++++- .../chat/prompts/PromptEditorViewModel.kt | 34 +++-- .../feature/chat/prompts/PromptsViewModel.kt | 4 +- 12 files changed, 288 insertions(+), 42 deletions(-) delete mode 100644 core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/UpdatePromptTagRequest.kt create mode 100644 core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/response/AddPromptToGroupResponse.kt create mode 100644 core/network/src/androidUnitTest/kotlin/com/garfiec/librechat/core/network/api/PromptsApiWireShapeTest.kt diff --git a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryRevisionTest.kt b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryRevisionTest.kt index 11be6e480..1a44ba04e 100644 --- a/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryRevisionTest.kt +++ b/core/data/src/androidUnitTest/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryRevisionTest.kt @@ -7,7 +7,6 @@ import com.garfiec.librechat.core.model.request.CreatePromptData import com.garfiec.librechat.core.model.request.CreatePromptGroupData import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import com.garfiec.librechat.core.network.api.PromptsApi import com.google.common.truth.Truth.assertThat import io.mockk.coEvery @@ -33,19 +32,23 @@ class PromptRepositoryRevisionTest { group = CreatePromptGroupData(name = "Group"), ) + private val addRequest = AddPromptToGroupRequest( + prompt = CreatePromptData(prompt = "new body", type = "text"), + ) + @Test fun everyAcceptedMutationBumpsTheRevision() = runTest { coEvery { promptsApi.createPrompt(any()) } returns group coEvery { promptsApi.updatePromptGroup(any(), any()) } returns group coEvery { promptsApi.deletePromptGroup(any()) } returns Unit coEvery { promptsApi.addPromptToGroup(any(), any()) } returns prompt - coEvery { promptsApi.updatePromptProductionTag(any(), any()) } returns prompt + coEvery { promptsApi.updatePromptProductionTag(any()) } returns Unit val start = repository.revision.value repository.create(createRequest) repository.update("g-1", UpdatePromptGroupRequest(name = "Group")) - repository.addPromptToGroup("g-1", AddPromptToGroupRequest(prompt = "new body", type = "text")) - repository.updatePromptProductionTag("p-1", UpdatePromptTagRequest(productionPromptId = "p-1")) + repository.addPromptToGroup("g-1", addRequest) + repository.updatePromptProductionTag("p-1") repository.delete("g-1") // One per mutation: a missed bump is a surface left serving pre-save values with nothing @@ -77,7 +80,7 @@ class PromptRepositoryRevisionTest { val start = repository.revision.value repository.create(createRequest) repository.delete("g-1") - repository.addPromptToGroup("g-1", AddPromptToGroupRequest(prompt = "new body", type = "text")) + repository.addPromptToGroup("g-1", addRequest) // The bump sits after the API call inside `safeApiCall`, so a throw skips it. Announcing a // failed delete would make the library refetch and repaint the prompt the user was told diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt index bdb678eae..d8fb7323a 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepository.kt @@ -6,7 +6,6 @@ import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import com.garfiec.librechat.core.model.response.PromptGroupListResponse import kotlinx.coroutines.flow.StateFlow @@ -32,8 +31,16 @@ interface PromptRepository { suspend fun create(request: CreatePromptRequest): Result suspend fun update(groupId: String, request: UpdatePromptGroupRequest): Result suspend fun delete(groupId: String): Result + + /** + * Adds a version to a group, answering the version created. A new version is not live until + * [updatePromptProductionTag] promotes it. + */ suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Result - suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result + + /** Promotes a version to its group's production prompt — the body every surface reads. */ + suspend fun updatePromptProductionTag(promptId: String): Result + suspend fun getPromptsByGroupId(groupId: String): Result> /** diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt index 1fc318385..78cbf6914 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/PromptRepositoryImpl.kt @@ -7,7 +7,6 @@ import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import com.garfiec.librechat.core.model.response.PromptGroupListResponse import com.garfiec.librechat.core.network.api.PromptsApi import kotlinx.coroutines.flow.MutableStateFlow @@ -69,9 +68,10 @@ class PromptRepositoryImpl( } } - override suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result { + override suspend fun updatePromptProductionTag(promptId: String): Result { return safeApiCall { - promptsApi.updatePromptProductionTag(promptId, request).also { bumpRevision() } + promptsApi.updatePromptProductionTag(promptId) + bumpRevision() } } diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddPromptToGroupRequest.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddPromptToGroupRequest.kt index ef009ea48..0a6449a85 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddPromptToGroupRequest.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/AddPromptToGroupRequest.kt @@ -2,8 +2,14 @@ package com.garfiec.librechat.core.model.request import kotlinx.serialization.Serializable +/** + * `POST /api/prompts/groups/{groupId}/prompts` body. + * + * The version's fields nest under `prompt`: the route reads `req.body.prompt.prompt`, so a flat + * body is rejected with HTTP 400. The group comes from the path — the route overwrites any + * `groupId` the body carries. See `feature/chat/CLAUDE.md`. + */ @Serializable data class AddPromptToGroupRequest( - val prompt: String, - val type: String, + val prompt: CreatePromptData, ) diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/UpdatePromptTagRequest.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/UpdatePromptTagRequest.kt deleted file mode 100644 index 472422290..000000000 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/request/UpdatePromptTagRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.garfiec.librechat.core.model.request - -import kotlinx.serialization.Serializable - -@Serializable -data class UpdatePromptTagRequest( - val productionPromptId: String? = null, -) diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/response/AddPromptToGroupResponse.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/response/AddPromptToGroupResponse.kt new file mode 100644 index 000000000..2274cc42d --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/response/AddPromptToGroupResponse.kt @@ -0,0 +1,17 @@ +package com.garfiec.librechat.core.model.response + +import com.garfiec.librechat.core.model.Prompt +import kotlinx.serialization.Serializable + +/** + * `POST /api/prompts/groups/{groupId}/prompts` response — the new version wrapped under `prompt`, + * not a bare [Prompt]. + * + * [prompt] is deliberately non-nullable: the route answers `{ "message": "Error saving prompt" }` + * with HTTP 200 when the write fails, so a nullable field would decode that as a success carrying + * nothing, and the caller promotes to production on the strength of it. + */ +@Serializable +data class AddPromptToGroupResponse( + val prompt: Prompt, +) diff --git a/core/network/src/androidUnitTest/kotlin/com/garfiec/librechat/core/network/api/PromptsApiWireShapeTest.kt b/core/network/src/androidUnitTest/kotlin/com/garfiec/librechat/core/network/api/PromptsApiWireShapeTest.kt new file mode 100644 index 000000000..0c0603da6 --- /dev/null +++ b/core/network/src/androidUnitTest/kotlin/com/garfiec/librechat/core/network/api/PromptsApiWireShapeTest.kt @@ -0,0 +1,117 @@ +package com.garfiec.librechat.core.network.api + +import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest +import com.garfiec.librechat.core.model.request.CreatePromptData +import com.garfiec.librechat.core.network.di.librechatJson +import com.google.common.truth.Truth.assertThat +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestData +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.contentType +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Test + +/** + * Pins the wire shapes of the two prompt routes whose request and response bodies do not match the + * bare-object convention the rest of `PromptsApi` follows. + * + * The bodies below are live captures from a v0.8.7 server. This has to live at `:core:network`: no + * test above it can see a JSON key, so a mismatch here compiles, type-checks, and surfaces only as + * a `Result.Error` the UI handles correctly. + */ +class PromptsApiWireShapeTest { + + private lateinit var lastRequest: HttpRequestData + + private fun api(responseBody: String, status: HttpStatusCode = HttpStatusCode.OK): PromptsApi { + val engine = MockEngine { request -> + lastRequest = request + respond( + content = responseBody, + status = status, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + val client = HttpClient(engine) { + install(ContentNegotiation) { json(librechatJson) } + // Mirrors production's defaultRequest, which is what makes the body serialize as JSON. + defaultRequest { contentType(ContentType.Application.Json) } + } + return PromptsApi(client) + } + + private fun sentBody(): String = (lastRequest.body as TextContent).text + + private val addRequest = AddPromptToGroupRequest( + prompt = CreatePromptData(prompt = "EDITED BODY", type = "text"), + ) + + /** A live 200 from `POST /api/prompts/groups/{id}/prompts`. */ + private val addResponse = """ + {"prompt":{"groupId":"6a7d506cfee2d387a18fc986","author":"6a797e6b09dd4560d08b49d6", + "prompt":"EDITED BODY","type":"text","_id":"6a7d506df4113f7e6c7140bc", + "createdAt":"2026-08-13T05:04:45.855Z","updatedAt":"2026-08-13T05:04:45.855Z","__v":0}} + """.trimIndent().replace("\n", "") + + @Test + fun `adding a version nests the body under prompt`() = runTest { + api(addResponse).addPromptToGroup("g-1", addRequest) + + // The route reads req.body.prompt.prompt. Sent flat, req.body.prompt is the text itself and + // the route answers 400 — which makes every body edit in the app impossible, on any server. + val body = Json.parseToJsonElement(sentBody()).jsonObject + val nested = body["prompt"]!!.jsonObject + assertThat(nested["prompt"]!!.jsonPrimitive.content).isEqualTo("EDITED BODY") + assertThat(nested["type"]!!.jsonPrimitive.content).isEqualTo("text") + } + + @Test + fun `adding a version unwraps the prompt the server answers with`() = runTest { + val added = api(addResponse).addPromptToGroup("g-1", addRequest) + + // savePrompt returns { prompt: … }, not a bare Prompt. The id is the point: it is the only + // place the new version's id appears, and promoting it to production needs it. + assertThat(added.id).isEqualTo("6a7d506df4113f7e6c7140bc") + assertThat(added.prompt).isEqualTo("EDITED BODY") + assertThat(added.groupId).isEqualTo("6a7d506cfee2d387a18fc986") + } + + @Test(expected = Exception::class) + fun `a failed add reports failure even though the route answers 200`() = runTest { + // savePrompt answers { message: "Error saving prompt" } — still HTTP 200 — when the write + // fails. Nothing above this can tell that from a success, so the decode has to: a nullable + // prompt hands the caller a success carrying nothing, and it promotes on the strength of it. + api("""{"message":"Error saving prompt"}""").addPromptToGroup("g-1", addRequest) + } + + @Test + fun `promoting a version sends no body and decodes none`() = runTest { + // The route promotes req.params.promptId and never reads req.body, so a body naming a + // different prompt is silently ignored while reading as though it chose which version + // went live. + api("""{"message":"Prompt production made successfully"}""").updatePromptProductionTag("p-2") + + assertThat(lastRequest.body).isNotInstanceOf(TextContent::class.java) + assertThat(lastRequest.url.encodedPath).isEqualTo("/api/prompts/p-2/tags/production") + } + + @Test + fun `promoting a version tolerates the message body the route answers with`() = runTest { + // The response is { message: … }, not the prompt. Decoding it as a Prompt throws + // MissingFieldException, turning an accepted promotion into a Result.Error — which skips + // the repository's revision bump, leaving the `/` picker on the superseded body. + api("""{"message":"Prompt production made successfully"}""").updatePromptProductionTag("p-2") + } +} diff --git a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt index 1dfef3042..ee08c11e1 100644 --- a/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt +++ b/core/network/src/commonMain/kotlin/com/garfiec/librechat/core/network/api/PromptsApi.kt @@ -5,7 +5,7 @@ import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest +import com.garfiec.librechat.core.model.response.AddPromptToGroupResponse import com.garfiec.librechat.core.model.response.CreatePromptResponse import com.garfiec.librechat.core.model.response.PromptGroupListResponse import io.ktor.client.HttpClient @@ -82,22 +82,32 @@ class PromptsApi constructor( } /** - * Add a prompt to an existing group. + * Adds a new version to an existing group, and answers the version it created. + * + * Adding a version does not make it live — the group's `productionId` still points at the old + * one, so callers editing a prompt must follow this with [updatePromptProductionTag], using the + * returned id. Request and response both nest under `prompt`; see [AddPromptToGroupRequest] and + * [AddPromptToGroupResponse]. */ suspend fun addPromptToGroup(groupId: String, request: AddPromptToGroupRequest): Prompt = client.post { url { path("api/prompts/groups/$groupId/prompts") } setBody(request) - }.body() + }.body().prompt /** - * Update the production tag for a prompt. + * Promotes a version to its group's production prompt — the body every prompt surface reads. + * + * Deliberately sends no body: the route promotes `req.params.promptId` and never reads + * `req.body`, so a body naming a different prompt is silently ignored while reading as though + * it chose. The response is `{ "message": ... }` with HTTP 200 either way, so there is nothing + * worth decoding; callers re-read the group to confirm the tag moved. */ - suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Prompt = + suspend fun updatePromptProductionTag(promptId: String) { client.patch { url { path("api/prompts/$promptId/tags/production") } - setBody(request) - }.body() + } + } /** * Get all prompts belonging to a group. diff --git a/feature/chat/CLAUDE.md b/feature/chat/CLAUDE.md index df68bf43b..e2697dfbd 100644 --- a/feature/chat/CLAUDE.md +++ b/feature/chat/CLAUDE.md @@ -197,13 +197,31 @@ Two consequences worth knowing before touching that block: `PROMPTS.USE` gate, so a denied user issues no request; the revision-driven library reload swallows its own errors (nobody asked for that fetch — only the pull-to-refresh gesture and the initial load report failure). -- **A prompt save is two or three requests, and `saved` may only flip when all of them landed.** +- **A prompt save is up to three requests, and `saved` may only flip when all of them landed.** The create route carries neither the oneliner nor the `/` command, and a body edit rides on `addPromptToGroup` rather than the group update — so each save reads every follow-up `Result` before reporting success. Every `PromptRepository` method is `safeApiCall`-wrapped: failure arrives as a returned `Result.Error`, never as a throw, so a `try/catch` around one is dead code. `PromptEditorScreen` pops on `saved`, so discarding a result loses the user's edit behind a success animation. +- **A body edit is not saved until the new version is promoted.** `addPromptToGroup` appends a + version and leaves the group's `productionId` pointing at the old one, so the edit is stored + where nothing reads it: the library row, the `/` picker and the editor itself all show the + *production* body. `publishNewVersion` therefore follows the add with `updatePromptProductionTag` + and reports the save on both, using the id from the add's response — the only place the new + version's id appears. Web does the same on save (`alwaysMakeProd`, default on); mobile exposes no + toggle for it, because a Save that visibly changes nothing is not an outcome worth offering. +- **Two prompt routes do not follow the bare-object convention, and both mismatches are invisible + from inside the app.** `POST /groups/{id}/prompts` nests the body under `prompt` in *both* + directions, and `PATCH /{id}/tags/production` takes no body and answers `{ message }` rather than + the prompt — sending HTTP 200 whether it worked or not. A flat add body is rejected with HTTP 400 + ("Prompt text is required and must be a non-empty string"), since the route reads + `req.body.prompt.prompt`; `AddPromptToGroupResponse.prompt` is non-nullable so that the 200-with- + `{ message }` failure fails to decode rather than reading as an empty success. Getting either + wrong compiles, type-checks and fails only against a real server, where it surfaces as a + `Result.Error` the UI handles correctly and a mutation the user cannot make. + `PromptsApiWireShapeTest` holds live captures of both; no test above `:core:network` can see a + JSON key. ## `ask_user_question`: one question, two cards (v0.8.8) diff --git a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorSaveOutcomeTest.kt b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorSaveOutcomeTest.kt index 60af2cd9a..997f848ee 100644 --- a/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorSaveOutcomeTest.kt +++ b/feature/chat/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorSaveOutcomeTest.kt @@ -119,21 +119,85 @@ class PromptEditorSaveOutcomeTest { assertFalse(state.saved) assertNotNull(state.error) assertFalse(state.isSaving) + // Nothing was written, so there is no version to promote. + coVerify(exactly = 0) { promptRepository.updatePromptProductionTag(any()) } } @Test - fun anAcceptedBodyUpdateReportsSaved() = runTest(testDispatcher) { + fun anAcceptedBodyUpdatePromotesTheNewVersionAndReportsSaved() = runTest(testDispatcher) { val editor = loadedEditor() advanceUntilIdle() coEvery { promptRepository.update(any(), any()) } returns Result.Success(createdGroup) coEvery { promptRepository.addPromptToGroup(any(), any()) } returns Result.Success(existingPrompt.copy(id = "p-2", prompt = "new body")) + coEvery { promptRepository.updatePromptProductionTag("p-2") } returns Result.Success(Unit) editor.updatePromptText("new body") editor.save() advanceUntilIdle() + // Adding a version does not move the group's productionId. Without the promotion the edit + // is stored where nothing reads it, so the user's change looks discarded. + coVerify(exactly = 1) { promptRepository.updatePromptProductionTag("p-2") } assertTrue(editor.uiState.value.saved) assertNull(editor.uiState.value.error) } + + @Test + fun aFailedPromotionDoesNotReportTheEditAsSaved() = runTest(testDispatcher) { + val editor = loadedEditor() + advanceUntilIdle() + coEvery { promptRepository.update(any(), any()) } returns Result.Success(createdGroup) + coEvery { promptRepository.addPromptToGroup(any(), any()) } returns + Result.Success(existingPrompt.copy(id = "p-2", prompt = "new body")) + coEvery { promptRepository.updatePromptProductionTag("p-2") } returns Result.Error(message = "offline") + + editor.updatePromptText("new body") + editor.save() + advanceUntilIdle() + + // The version exists but is not live, so the edit is not in effect anywhere the user can + // see. Popping the editor here would animate success over a prompt that still answers with + // the old body. + val state = editor.uiState.value + assertFalse(state.saved) + assertNotNull(state.error) + assertFalse(state.isSaving) + } + + @Test + fun aNewVersionWithNoIdIsNotReportedAsSaved() = runTest(testDispatcher) { + val editor = loadedEditor() + advanceUntilIdle() + coEvery { promptRepository.update(any(), any()) } returns Result.Success(createdGroup) + coEvery { promptRepository.addPromptToGroup(any(), any()) } returns + Result.Success(existingPrompt.copy(id = null, prompt = "new body")) + + editor.updatePromptText("new body") + editor.save() + advanceUntilIdle() + + // The response is the only place the new version's id appears, so no id means no promotion + // — and an unpromoted version is an edit nothing will read. + coVerify(exactly = 0) { promptRepository.updatePromptProductionTag(any()) } + assertFalse(editor.uiState.value.saved) + assertNotNull(editor.uiState.value.error) + } + + @Test + fun anUnchangedBodyWritesNoVersionAndSaves() = runTest(testDispatcher) { + val editor = loadedEditor() + advanceUntilIdle() + coEvery { promptRepository.update(any(), any()) } returns Result.Success(createdGroup) + + editor.updateCommand("summarize") + editor.save() + advanceUntilIdle() + + // Renaming or re-commanding a prompt must not mint a version identical to the live one — + // the version history is the user's, and every save would otherwise add a row to it. + coVerify(exactly = 0) { promptRepository.addPromptToGroup(any(), any()) } + coVerify(exactly = 0) { promptRepository.updatePromptProductionTag(any()) } + assertTrue(editor.uiState.value.saved) + } } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt index 1bb3a98d1..dc5e39794 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt @@ -11,7 +11,6 @@ import com.garfiec.librechat.core.model.request.CreatePromptData import com.garfiec.librechat.core.model.request.CreatePromptGroupData import com.garfiec.librechat.core.model.request.CreatePromptRequest import com.garfiec.librechat.core.model.request.UpdatePromptGroupRequest -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -119,8 +118,7 @@ class PromptEditorViewModel( val state = _uiState.value val groupId = state.groupId ?: return viewModelScope.launch { - val request = UpdatePromptTagRequest(productionPromptId = promptId) - when (val result = promptRepository.updatePromptProductionTag(promptId, request)) { + when (val result = promptRepository.updatePromptProductionTag(promptId)) { is Result.Success -> { _uiState.value = _uiState.value.copy( productionId = promptId, @@ -212,16 +210,12 @@ class PromptEditorViewModel( ) when (val result = promptRepository.update(groupId, updateRequest)) { is Result.Success -> { - // A changed body is added as a new version. This call carries the body — the + // A changed body is added as a new version. Those calls carry the body — the // metadata update above does not — and the screen pops on `saved`, so flipping it - // without reading this result loses the user's edit behind a success animation. + // without reading their results loses the user's edit behind a success animation. val currentProduction = state.prompts.find { it.id == state.productionId } val textSaved = if (currentProduction == null || currentProduction.prompt != state.promptText) { - val addRequest = AddPromptToGroupRequest( - prompt = state.promptText, - type = "text", - ) - promptRepository.addPromptToGroup(groupId, addRequest) is Result.Success + publishNewVersion(groupId, state.promptText) } else { true } @@ -241,6 +235,26 @@ class PromptEditorViewModel( } } + /** + * Writes the edited body as a new version and makes it the live one, reporting whether both + * halves landed. + * + * Adding a version does not move the group's `productionId`, so the promotion is not a + * refinement — without it the edit is stored where nothing reads it. Every surface shows the + * *production* body: the library row, the composer's `/` picker, and this editor when it + * reopens. + */ + private suspend fun publishNewVersion(groupId: String, text: String): Boolean { + val addRequest = AddPromptToGroupRequest( + prompt = CreatePromptData(prompt = text, type = "text"), + ) + val added = promptRepository.addPromptToGroup(groupId, addRequest) + // The response is the only place the new version's id appears, so no id means no promotion + // — report the save as incomplete rather than popping the editor over an unread edit. + val newPromptId = (added as? Result.Success)?.data?.id ?: return false + return promptRepository.updatePromptProductionTag(newPromptId) is Result.Success + } + fun consumeSaved() { _uiState.value = _uiState.value.copy(saved = false) } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt index b2d2c16a4..b53216054 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptsViewModel.kt @@ -12,7 +12,6 @@ import com.garfiec.librechat.core.model.PromptGroup import com.garfiec.librechat.core.model.permissions.Permission import com.garfiec.librechat.core.model.permissions.PermissionType import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive -import com.garfiec.librechat.core.model.request.UpdatePromptTagRequest import com.garfiec.librechat.feature.chat.prompts.components.PromptSortOrder import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -261,8 +260,7 @@ class PromptsViewModel( fun setProductionTag(promptId: String) { viewModelScope.launch { - val request = UpdatePromptTagRequest(productionPromptId = promptId) - when (val result = promptRepository.updatePromptProductionTag(promptId, request)) { + when (val result = promptRepository.updatePromptProductionTag(promptId)) { is Result.Success -> { // Reload the selected group to reflect the new production tag val groupId = _uiState.value.selectedGroup?.id