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
11 changes: 11 additions & 0 deletions .tegami/2026-08-07-assistant-message-length.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
packages:
orgmemory: patch
subject: Enforce the supported Assistant question length
---

## Fixes

The Assistant composer now displays and enforces the 1,000-character question
limit before a turn starts. Questions at the boundary remain accepted, while
longer input is blocked instead of opening a stream that later fails.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import java.util.UUID;

record AssistantChatRequest(
@NotBlank @Size(max = 4_000) String message,
@NotBlank @Size(max = 1_000) String message,
Integer limit,
UUID conversationId,
UUID modelActivationId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.orgmemory.api.assistant;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;

import com.orgmemory.api.security.CurrentActorProvider;
import com.orgmemory.core.ai.AssistantModelAuthorityService;
import com.orgmemory.core.assistant.AssistantConversationService;
import com.orgmemory.core.assistant.AssistantService;
import com.orgmemory.core.knowledge.retrieval.CitationEvidenceService;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.ObjectMapper;

class AssistantChatRequestValidationTests {

@Test
void enforcesTheMessageLimitBoundary() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var accepted = validator.validate(
new AssistantChatRequest("a".repeat(1_000), null, null, null));
var rejected = validator.validate(
new AssistantChatRequest("a".repeat(1_001), null, null, null));

assertEquals(0, accepted.size());
assertEquals(1, rejected.size());
assertEquals(
"message",
rejected.iterator().next().getPropertyPath().toString());
}
}

@Test
void rejectsAnOversizedMessageBeforeOpeningTheStreamOrCreatingATurn() throws Exception {
var assistant = mock(AssistantService.class);
var conversations = mock(AssistantConversationService.class);
var actors = mock(CurrentActorProvider.class);
var properties = mock(AssistantProperties.class);
var modelAuthority = mock(AssistantModelAuthorityService.class);
var citationEvidence = mock(CitationEvidenceService.class);
var retrievalScheduler = mock(AssistantRetrievalScheduler.class);
var json = mock(ObjectMapper.class);
var mvc = standaloneSetup(new AssistantController(
assistant,
conversations,
actors,
properties,
modelAuthority,
citationEvidence,
retrievalScheduler,
json))
.build();

mvc.perform(post("/api/assistant/chat")
.contentType(APPLICATION_JSON)
.accept(TEXT_EVENT_STREAM)
.content("{\"message\":\"" + "a".repeat(1_001) + "\"}"))
.andExpect(status().isBadRequest());

verifyNoInteractions(
assistant,
conversations,
actors,
properties,
modelAuthority,
citationEvidence,
retrievalScheduler,
json);
}
}
2 changes: 1 addition & 1 deletion apps/docs/generated/openapi.public.json
Original file line number Diff line number Diff line change
Expand Up @@ -7303,7 +7303,7 @@
"properties": {
"message": {
"type": "string",
"maxLength": 4000,
"maxLength": 1000,
"minLength": 0
},
"limit": {
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/features/assistant/assistant-draft-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@ describe("assistant draft storage", () => {
expect(readAssistantDraft("actor-b", "conversation-1")).toBe("other actor")
})

it("bounds drafts persisted by an older client", () => {
sessionStorage.setItem(
"orgmemory:assistant-draft:v1:actor-a:new",
"x".repeat(1_100),
)

expect(readAssistantDraft("actor-a")).toHaveLength(1_000)
})

it("caps drafts at the server message limit and clears lifecycle scopes", () => {
const bounded = writeAssistantDraft("actor-a", "conversation-1", "x".repeat(4_100))
expect(bounded).toHaveLength(4_000)
const bounded = writeAssistantDraft("actor-a", "conversation-1", "x".repeat(1_100))
expect(bounded).toHaveLength(1_000)

clearAssistantDraft("actor-a", "conversation-1")
expect(readAssistantDraft("actor-a", "conversation-1")).toBe("")
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/features/assistant/assistant-draft-storage.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { ASSISTANT_MESSAGE_MAX_CHARACTERS } from "@/features/assistant/assistant-message-constraints"

const DRAFT_PREFIX = "orgmemory:assistant-draft:v1:"
const MAX_DRAFT_LENGTH = 4_000

function draftKey(actorKey: string, conversationId?: string) {
return `${DRAFT_PREFIX}${encodeURIComponent(actorKey)}:${conversationId ?? "new"}`
}

export function readAssistantDraft(actorKey: string, conversationId?: string) {
try {
return sessionStorage.getItem(draftKey(actorKey, conversationId)) ?? ""
return (sessionStorage.getItem(draftKey(actorKey, conversationId)) ?? "").slice(
0,
ASSISTANT_MESSAGE_MAX_CHARACTERS,
)
} catch {
return ""
}
Expand All @@ -18,7 +22,7 @@ export function writeAssistantDraft(
conversationId: string | undefined,
value: string,
) {
const bounded = value.slice(0, MAX_DRAFT_LENGTH)
const bounded = value.slice(0, ASSISTANT_MESSAGE_MAX_CHARACTERS)
const key = draftKey(actorKey, conversationId)
try {
if (bounded.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ASSISTANT_MESSAGE_MAX_CHARACTERS = 1_000
23 changes: 21 additions & 2 deletions apps/web/src/features/assistant/components/assistant-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { Source, Sources, SourcesContent, SourcesTrigger } from "@/components/ai
import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"
import { Button } from "@/components/ui/button"
import { createAssistantTransport } from "@/features/assistant/api/chat-transport"
import { ASSISTANT_MESSAGE_MAX_CHARACTERS } from "@/features/assistant/assistant-message-constraints"
import {
activityLabel,
hasVisibleAssistantOutput,
Expand Down Expand Up @@ -741,6 +742,12 @@ export function AssistantPage({

function send(rawMessage: string, clearComposer = true) {
const message = rawMessage.trim()
if (message.length > ASSISTANT_MESSAGE_MAX_CHARACTERS) {
toast.error(
`Messages can be at most ${ASSISTANT_MESSAGE_MAX_CHARACTERS.toLocaleString("en-US")} characters.`,
)
return
}
if (
!message ||
busy ||
Expand Down Expand Up @@ -806,10 +813,15 @@ export function AssistantPage({
<PromptInputBody>
<PromptInputTextarea
value={text}
onChange={(event) => setText(event.currentTarget.value)}
onChange={(event) =>
setText(
event.currentTarget.value.slice(0, ASSISTANT_MESSAGE_MAX_CHARACTERS),
)
}
placeholder="Ask OrgMemory…"
autoFocus
maxLength={4_000}
maxLength={ASSISTANT_MESSAGE_MAX_CHARACTERS}
aria-describedby="assistant-message-length"
className="min-h-12"
/>
</PromptInputBody>
Expand All @@ -822,6 +834,13 @@ export function AssistantPage({
loading={modelOptions.isPending}
onSelect={chooseModel}
/>
<span
id="assistant-message-length"
className="text-metadata tabular-nums text-content-muted"
>
{text.length.toLocaleString("en-US")} /{" "}
{ASSISTANT_MESSAGE_MAX_CHARACTERS.toLocaleString("en-US")} characters
</span>
</PromptInputTools>
<PromptInputSubmit
status={status}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/test/e2e/assistant-pipeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,27 @@ test("stop aborts one in-flight assistant request", async ({ page }) => {
expect(harness.browserErrors).toEqual([])
})

test("aligns the composer with the server query limit", async ({ page }) => {
const harness = await assistantHarness(page)
await page.goto("/")

const composer = page.getByPlaceholder("Ask OrgMemory…")
await expect(composer).toHaveAttribute("maxlength", "1000")
await expect(composer).toHaveAttribute(
"aria-describedby",
"assistant-message-length",
)
const maximumMessage = "a".repeat(1_000)
await composer.fill(maximumMessage)
const counter = page.getByText("1,000 / 1,000 characters")
await expect(counter).toBeVisible()
await expect(counter).toHaveAttribute("id", "assistant-message-length")
await composer.press("a")
await expect(composer).toHaveValue(maximumMessage)
expect(harness.chatBodies).toEqual([])
expect(harness.unexpectedRequests).toEqual([])
})

test("loads server-owned starters and restores a session-scoped draft with focus", async ({ page }) => {
const harness = await assistantHarness(page)
await page.goto("/")
Expand Down
2 changes: 1 addition & 1 deletion contracts/openapi.json

Large diffs are not rendered by default.