Skip to content
Open
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
21 changes: 17 additions & 4 deletions src/agent_manager/api/static/widget/react/AgentChatApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ type MessageEntry = {
typing?: boolean;
route?: string[];
tools?: ToolRecord[];
/** When true the message represents a permanent error (e.g. budget exceeded). */
error?: boolean;
};

let nextMessageCounter = 0;
Expand Down Expand Up @@ -150,6 +152,9 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age
setEntries((prev) => prev.map((entry) => (entry.id === id ? update(entry) : entry)));
}, []);

const BUDGET_EXCEEDED_MESSAGE =
"This conversation has reached its context limit. Please start a new chat to continue.";

const submit = useCallback(
async (text: string) => {
const assistantId = nextMessageId("ai");
Expand All @@ -167,6 +172,16 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age
patchEntry(assistantId, (entry) => ({ ...entry, typing: false }));
onAnswer(finalDetail);
} catch (error) {
if (error instanceof AgentChatHttpError && error.status === 429) {
patchEntry(assistantId, () => ({
id: assistantId,
role: "ai",
text: BUDGET_EXCEEDED_MESSAGE,
error: true,
}));
setSending(false);
return;
}
try {
const data = await sendToAgent(text);
patchEntry(assistantId, () => ({
Expand All @@ -181,10 +196,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age
patchEntry(assistantId, () => ({
id: assistantId,
role: "ai",
text:
error instanceof Error && error.message
? `Something went wrong. Please try again.`
: "Something went wrong. Please try again.",
text: "Something went wrong. Please try again.",
}));
}
} finally {
Expand Down Expand Up @@ -256,6 +268,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age
key={entry.id}
from={entry.role === "user" ? "user" : "assistant"}
typing={entry.typing}
error={entry.error}
>
{entry.typing ? (
"..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ export function Message({
children,
from,
typing = false,
}: PropsWithChildren<{ from: "user" | "assistant"; typing?: boolean }>) {
error = false,
}: PropsWithChildren<{ from: "user" | "assistant"; typing?: boolean; error?: boolean }>) {
return (
<div
className={cn("msg", from === "user" ? "user" : "ai", typing && "typing")}
className={cn("msg", from === "user" ? "user" : "ai", typing && "typing", error && "msg-error")}
role={typing ? "status" : undefined}
aria-label={typing ? "Assistant is typing" : undefined}
>
Expand All @@ -56,6 +57,7 @@ export function Message({
);
}


export function MessageContent({ children }: PropsWithChildren) {
return <div className="message-content">{children}</div>;
}
Expand Down
2 changes: 2 additions & 0 deletions src/agent_manager/api/static/widget/styles/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export function styles(config: AgentChatConfig): string {
.msg { font-size: 14.5px; line-height: 1.55; word-wrap: break-word; white-space: pre-wrap; }
.msg.ai { color: #18181b; max-width: 100%; }
.msg.ai.typing { color: #a1a1aa; letter-spacing: 1px; }
.msg.ai.msg-error { color: #991b1b; background: #fff5f5; border: 1px solid #fecaca;
border-radius: 10px; padding: 8px 12px; }
.msg.user { background: #f4f4f5; color: #18181b; border-radius: 18px;
padding: 10px 14px; margin-left: auto; max-width: 88%; }
.message-content { min-width: 0; }
Expand Down
8 changes: 7 additions & 1 deletion tests/agent_manager/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ async def run(
self.prompts.append(message)
self.histories.append(tuple(history))
self.contexts.append(context)
return RunResult(system_name="stub", visited=["agent"], answer=f"answer:{message}")
return RunResult(
system_name="stub",
visited=["agent"],
answer=f"answer:{message}",
input_tokens=5,
output_tokens=5,
)

async def stream(
self,
Expand Down
38 changes: 38 additions & 0 deletions tests/agent_manager/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,41 @@ def test_create_accepts_stable_session_and_send_accepts_user(client: TestClient)
sent = client.post("/conversations/sess-1/messages", json={"message": "hello", "user_id": "u1"})

assert sent.status_code == 200


def test_send_returns_429_when_token_budget_exceeded() -> None:
"""send_message returns 429 when the conversation token budget is exhausted."""
app = FastAPI()
# max_tokens=1 means the budget is treated as exceeded as soon as any token is recorded.
# To trigger the guard we first send a real message (consumes tokens), then send a second one.
service = ConversationService(RecordingEngine(), MemoryRepository(), max_tokens=1)
app.state.service = service
app.include_router(router)
client = TestClient(app)

cid = client.post("/conversations").json()["conversation_id"]
# First message succeeds and records token usage in the repository.
client.post(f"/conversations/{cid}/messages", json={"message": "first"})
# Second message should be rejected because the budget is now exhausted.
response = client.post(f"/conversations/{cid}/messages", json={"message": "second"})

assert response.status_code == 429
assert "budget" in response.json()["detail"]


def test_stream_returns_429_when_token_budget_exceeded() -> None:
"""stream_message returns 429 when the conversation token budget is exhausted."""
app = FastAPI()
service = ConversationService(RecordingEngine(), MemoryRepository(), max_tokens=1)
app.state.service = service
app.include_router(router)
client = TestClient(app)

cid = client.post("/conversations").json()["conversation_id"]
# Consume the budget with a non-streaming send first.
client.post(f"/conversations/{cid}/messages", json={"message": "first"})
# The streaming endpoint should now return 429.
response = client.post(f"/conversations/{cid}/messages/stream", json={"message": "second"})

assert response.status_code == 429
assert "budget" in response.json()["detail"]
Loading