Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -32,8 +31,16 @@ interface PromptRepository {
suspend fun create(request: CreatePromptRequest): Result<PromptGroup>
suspend fun update(groupId: String, request: UpdatePromptGroupRequest): Result<PromptGroup>
suspend fun delete(groupId: String): Result<Unit>

/**
* 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<Prompt>
suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result<Prompt>

/** Promotes a version to its group's production prompt — the body every surface reads. */
suspend fun updatePromptProductionTag(promptId: String): Result<Unit>

suspend fun getPromptsByGroupId(groupId: String): Result<List<Prompt>>

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,9 +68,10 @@ class PromptRepositoryImpl(
}
}

override suspend fun updatePromptProductionTag(promptId: String, request: UpdatePromptTagRequest): Result<Prompt> {
override suspend fun updatePromptProductionTag(promptId: String): Result<Unit> {
return safeApiCall {
promptsApi.updatePromptProductionTag(promptId, request).also { bumpRevision() }
promptsApi.updatePromptProductionTag(promptId)
bumpRevision()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AddPromptToGroupResponse>().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.
Expand Down
20 changes: 19 additions & 1 deletion feature/chat/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading