Skip to content

Commit d2bcd81

Browse files
authored
fix: harden chat and runtime reliability (#123)
1 parent d5b49ea commit d2bcd81

7 files changed

Lines changed: 463 additions & 49 deletions

File tree

backend/cortex_backend/api/routes.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1313,7 +1313,21 @@ def cancel_generation(
13131313
_raise_job_error(exc)
13141314
return _job_response(snapshot)
13151315

1316-
@router.get("/generations/{job_id}/events", response_model=GenerationEvent)
1316+
@router.get(
1317+
"/generations/{job_id}/events",
1318+
response_model=GenerationEvent,
1319+
response_class=StreamingResponse,
1320+
responses={
1321+
200: {
1322+
"description": "Server-sent generation events.",
1323+
"content": {
1324+
"text/event-stream": {
1325+
"schema": {"$ref": "#/components/schemas/GenerationEvent"}
1326+
}
1327+
},
1328+
}
1329+
},
1330+
)
13171331
async def generation_events(
13181332
job_id: str,
13191333
request: Request,

backend/cortex_backend/llamacpp/server_manager.py

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -552,35 +552,43 @@ def _start_with_backend(
552552
base_url = f"http://127.0.0.1:{port}"
553553
deadline = time.monotonic() + self._health_timeout_seconds
554554
last_status_at = time.monotonic()
555-
while time.monotonic() < deadline:
556-
exit_code = process.poll()
557-
if exit_code is not None:
558-
if backend == "vulkan":
559-
self._mark_backend_bad("vulkan")
560-
raise ServerLaunchError(
561-
"The local model runtime exited before it became ready.\n"
562-
+ "\n".join(stderr_tail[-20:])
563-
)
564-
if self._probe_health(base_url):
565-
with self._state_lock:
566-
self._process = process
567-
self._loaded_model_path = model_path
568-
self._loaded_num_ctx = num_ctx
569-
self._base_url = base_url
570-
self._state = "ready"
571-
self._last_error = None
572-
self._active_backend = backend
573-
self._last_health_check = time.monotonic()
574-
self._stderr_tail = stderr_tail
575-
return ServerHandle(base_url=base_url, model_path=model_path)
576-
now = time.monotonic()
577-
if on_status is not None and now - last_status_at >= _STATUS_REPEAT_SECONDS:
578-
on_status(f"Still loading the model ({model_path.name})... this can take a while for large files.")
579-
last_status_at = now
580-
time.sleep(_HEALTH_POLL_INTERVAL_SECONDS)
581-
582-
process.terminate()
583-
raise ServerStartTimeoutError("The local model runtime did not become ready in time.")
555+
ready = False
556+
try:
557+
while time.monotonic() < deadline:
558+
exit_code = process.poll()
559+
if exit_code is not None:
560+
if backend == "vulkan":
561+
self._mark_backend_bad("vulkan")
562+
raise ServerLaunchError(
563+
"The local model runtime exited before it became ready.\n"
564+
+ "\n".join(stderr_tail[-20:])
565+
)
566+
if self._probe_health(base_url):
567+
with self._state_lock:
568+
self._process = process
569+
self._loaded_model_path = model_path
570+
self._loaded_num_ctx = num_ctx
571+
self._base_url = base_url
572+
self._state = "ready"
573+
self._last_error = None
574+
self._active_backend = backend
575+
self._last_health_check = time.monotonic()
576+
self._stderr_tail = stderr_tail
577+
ready = True
578+
return ServerHandle(base_url=base_url, model_path=model_path)
579+
now = time.monotonic()
580+
if on_status is not None and now - last_status_at >= _STATUS_REPEAT_SECONDS:
581+
on_status(f"Still loading the model ({model_path.name})... this can take a while for large files.")
582+
last_status_at = now
583+
time.sleep(_HEALTH_POLL_INTERVAL_SECONDS)
584+
585+
raise ServerStartTimeoutError("The local model runtime did not become ready in time.")
586+
finally:
587+
# The manager does not publish the process into ``self._process``
588+
# until health succeeds. Reap every failed startup here so a
589+
# timeout or callback error cannot leave an unowned model process.
590+
if not ready and process.poll() is None:
591+
self._terminate_process(process)
584592

585593
def _probe_health(self, base_url: str) -> bool:
586594
try:

contracts/openapi.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4599,13 +4599,13 @@
45994599
"responses": {
46004600
"200": {
46014601
"content": {
4602-
"application/json": {
4602+
"text/event-stream": {
46034603
"schema": {
46044604
"$ref": "#/components/schemas/GenerationEvent"
46054605
}
46064606
}
46074607
},
4608-
"description": "Successful Response"
4608+
"description": "Server-sent generation events."
46094609
},
46104610
"422": {
46114611
"content": {

frontend/src/features/chat/ChatPage.test.tsx

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,140 @@ describe("ChatPage composer integration", () => {
262262
expect(screen.getByRole("button", { name: "Stop generating" })).toBeInTheDocument();
263263
});
264264

265+
it("moves drafts created during new-chat acceptance into the accepted thread", async () => {
266+
const user = userEvent.setup();
267+
const lateAttachment: ChatAttachment = {
268+
attachment_id: "late-doc",
269+
filename: "next-turn.md",
270+
mime_type: "text/markdown",
271+
size: 9,
272+
sha256: "1".repeat(64),
273+
kind: "document",
274+
expires_at: "2099-01-01T00:00:00Z",
275+
};
276+
let accept!: (value: { job_id: string; kind: "generation"; status: "queued"; thread_id: string; user_message_id: string }) => void;
277+
const accepted = new Promise<{ job_id: string; kind: "generation"; status: "queued"; thread_id: string; user_message_id: string }>((resolve) => { accept = resolve; });
278+
const api = chatApi({
279+
chat: vi.fn(async (id: string) => emptyChat(id)),
280+
generate: vi.fn(() => accepted),
281+
stageChatAttachment: vi.fn().mockResolvedValue(lateAttachment),
282+
});
283+
function RoutedChat() {
284+
const [threadId, setThreadId] = useState<string | null>(null);
285+
return (
286+
<ChatPage
287+
api={api}
288+
threadId={threadId}
289+
runtimeReady
290+
runtimeMessage={null}
291+
localModels={["local-chat:7b"]}
292+
selectedModel="local-chat:7b"
293+
modelBusy={false}
294+
onSelectModel={async () => true}
295+
onRescanModels={async () => undefined}
296+
onThreadCreated={setThreadId}
297+
onChatChanged={vi.fn()}
298+
onForked={vi.fn()}
299+
/>
300+
);
301+
}
302+
render(<RoutedChat />);
303+
304+
const composer = await screen.findByLabelText("Message Cortex");
305+
const attachmentInput = screen.getByLabelText("Attach images or documents");
306+
await user.type(composer, "First turn");
307+
await user.click(screen.getByRole("button", { name: "Send message" }));
308+
await waitFor(() => expect(api.generate).toHaveBeenCalledTimes(1));
309+
310+
await user.clear(composer);
311+
await user.type(composer, "Draft for the next turn");
312+
await user.upload(attachmentInput, new File(["next turn"], "next-turn.md", { type: "text/markdown" }));
313+
expect(await screen.findByRole("button", { name: "Remove next-turn.md" })).toBeInTheDocument();
314+
315+
accept({
316+
job_id: "job-new",
317+
kind: "generation",
318+
status: "queued",
319+
thread_id: "thread-new",
320+
user_message_id: "message-new",
321+
});
322+
323+
await waitFor(() => expect(screen.getByLabelText("Message Cortex")).toHaveValue("Draft for the next turn"));
324+
expect(screen.getByRole("button", { name: "Remove next-turn.md" })).toBeInTheDocument();
325+
expect(window.sessionStorage.getItem("cortex.composer.draft.new")).toBeNull();
326+
expect(window.sessionStorage.getItem("cortex.composer.draft.thread-new")).toBe("Draft for the next turn");
327+
expect(window.sessionStorage.getItem("cortex.composer.attachments.new")).toBeNull();
328+
expect(JSON.parse(window.sessionStorage.getItem("cortex.composer.attachments.thread-new") ?? "[]")).toEqual([lateAttachment]);
329+
});
330+
331+
it("retargets an in-flight new-chat attachment when acceptance wins the race", async () => {
332+
const user = userEvent.setup();
333+
const stagedAttachment: ChatAttachment = {
334+
attachment_id: "inverse-order-doc",
335+
filename: "after-acceptance.md",
336+
mime_type: "text/markdown",
337+
size: 16,
338+
sha256: "2".repeat(64),
339+
kind: "document",
340+
expires_at: "2099-01-01T00:00:00Z",
341+
};
342+
let accept!: (value: { job_id: string; kind: "generation"; status: "queued"; thread_id: string; user_message_id: string }) => void;
343+
let finishStaging!: (value: ChatAttachment) => void;
344+
const accepted = new Promise<{ job_id: string; kind: "generation"; status: "queued"; thread_id: string; user_message_id: string }>((resolve) => { accept = resolve; });
345+
const staging = new Promise<ChatAttachment>((resolve) => { finishStaging = resolve; });
346+
const api = chatApi({
347+
chat: vi.fn(async (id: string) => emptyChat(id)),
348+
generate: vi.fn(() => accepted),
349+
stageChatAttachment: vi.fn(() => staging),
350+
});
351+
function RoutedChat() {
352+
const [threadId, setThreadId] = useState<string | null>(null);
353+
return (
354+
<ChatPage
355+
api={api}
356+
threadId={threadId}
357+
runtimeReady
358+
runtimeMessage={null}
359+
localModels={["local-chat:7b"]}
360+
selectedModel="local-chat:7b"
361+
modelBusy={false}
362+
onSelectModel={async () => true}
363+
onRescanModels={async () => undefined}
364+
onThreadCreated={setThreadId}
365+
onChatChanged={vi.fn()}
366+
onForked={vi.fn()}
367+
/>
368+
);
369+
}
370+
render(<RoutedChat />);
371+
372+
const composer = await screen.findByLabelText("Message Cortex");
373+
await user.type(composer, "First turn");
374+
await user.click(screen.getByRole("button", { name: "Send message" }));
375+
await waitFor(() => expect(api.generate).toHaveBeenCalledTimes(1));
376+
await user.upload(
377+
screen.getByLabelText("Attach images or documents"),
378+
new File(["after acceptance"], "after-acceptance.md", { type: "text/markdown" }),
379+
);
380+
await waitFor(() => expect(api.stageChatAttachment).toHaveBeenCalledTimes(1));
381+
382+
accept({
383+
job_id: "job-inverse",
384+
kind: "generation",
385+
status: "queued",
386+
thread_id: "thread-inverse",
387+
user_message_id: "message-inverse",
388+
});
389+
await waitFor(() => expect(api.chat).toHaveBeenCalledWith("thread-inverse"));
390+
expect(window.sessionStorage.getItem("cortex.composer.attachments.new")).toBeNull();
391+
392+
act(() => finishStaging(stagedAttachment));
393+
394+
expect(await screen.findByRole("button", { name: "Remove after-acceptance.md" })).toBeInTheDocument();
395+
expect(window.sessionStorage.getItem("cortex.composer.attachments.new")).toBeNull();
396+
expect(JSON.parse(window.sessionStorage.getItem("cortex.composer.attachments.thread-inverse") ?? "[]")).toEqual([stagedAttachment]);
397+
});
398+
265399
it("replays an active generation from the beginning after a remount", async () => {
266400
window.sessionStorage.setItem("cortex.active.generation", JSON.stringify({ jobId: "job-replay", threadId: "thread-a", lastEventId: 7 }));
267401
const streamCalls: Array<{ afterEventId?: number }> = [];
@@ -429,6 +563,94 @@ describe("ChatPage composer integration", () => {
429563
expect(screen.getByLabelText("Message Cortex")).toBeEnabled();
430564
});
431565

566+
it("regenerates from the selected user turn instead of stale cross-chat or composer state", async () => {
567+
const user = userEvent.setup();
568+
const originalAttachment: ChatAttachment = {
569+
attachment_id: "original-doc",
570+
filename: "original.md",
571+
mime_type: "text/markdown",
572+
size: 12,
573+
sha256: "d".repeat(64),
574+
kind: "document",
575+
expires_at: "2099-01-01T00:00:00Z",
576+
};
577+
const draftAttachment: ChatAttachment = {
578+
attachment_id: "next-draft-doc",
579+
filename: "next-draft.md",
580+
mime_type: "text/markdown",
581+
size: 14,
582+
sha256: "e".repeat(64),
583+
kind: "document",
584+
expires_at: "2099-01-01T00:00:00Z",
585+
};
586+
const threadA = emptyChat("thread-a");
587+
const threadB: ChatResponse = {
588+
...emptyChat("thread-b"),
589+
revision: 2,
590+
messages: [
591+
{ id: "user-b", role: "user", content: "Prompt from B", attachments: [originalAttachment] },
592+
{ id: "assistant-b", role: "assistant", content: "Answer from B" },
593+
],
594+
};
595+
const api = chatApi({
596+
chat: vi.fn(async (id: string) => id === "thread-b" ? threadB : threadA),
597+
generate: vi.fn().mockResolvedValue({
598+
job_id: "job-a", kind: "generation", status: "queued", thread_id: "thread-a", user_message_id: "user-a",
599+
}),
600+
regenerate: vi.fn().mockResolvedValue({
601+
job_id: "job-b", kind: "generation", status: "queued", thread_id: "thread-b",
602+
}),
603+
stageChatAttachment: vi.fn().mockResolvedValue(draftAttachment),
604+
streamGeneration: vi.fn(async (jobId, onEvent) => {
605+
const completedThreadId = jobId === "job-a" ? "thread-a" : "thread-b";
606+
onEvent({
607+
event_id: 1,
608+
event: "generation.completed",
609+
job_id: jobId,
610+
thread_id: completedThreadId,
611+
data: {},
612+
});
613+
}),
614+
});
615+
const view = renderChat(api, "thread-a");
616+
const composer = await screen.findByLabelText("Message Cortex");
617+
await user.type(composer, "Prompt from A");
618+
await user.click(screen.getByRole("button", { name: "Send message" }));
619+
await waitFor(() => expect(window.sessionStorage.getItem("cortex.active.generation")).toBeNull());
620+
621+
view.rerender(
622+
<ChatPage
623+
api={api}
624+
threadId="thread-b"
625+
runtimeReady
626+
runtimeMessage={null}
627+
localModels={["local-chat:7b"]}
628+
selectedModel="local-chat:7b"
629+
modelBusy={false}
630+
onSelectModel={async () => true}
631+
onRescanModels={async () => undefined}
632+
onThreadCreated={vi.fn()}
633+
onChatChanged={vi.fn()}
634+
onForked={vi.fn()}
635+
/>,
636+
);
637+
expect(await screen.findByText("Answer from B")).toBeInTheDocument();
638+
await user.upload(
639+
screen.getByLabelText("Attach images or documents"),
640+
new File(["next"], "next-draft.md", { type: "text/markdown" }),
641+
);
642+
expect(await screen.findByRole("button", { name: "Remove next-draft.md" })).toBeInTheDocument();
643+
644+
await user.click(screen.getByRole("button", { name: "Regenerate response" }));
645+
646+
await waitFor(() => expect(api.regenerate).toHaveBeenCalledWith("thread-b", expect.objectContaining({
647+
message_id: "assistant-b",
648+
user_input: "Prompt from B",
649+
attachments: [originalAttachment],
650+
})));
651+
expect(screen.getByRole("button", { name: "Remove next-draft.md" })).toBeInTheDocument();
652+
});
653+
432654
it("stages a document without putting its contents into the composer and sends its opaque metadata", async () => {
433655
const user = userEvent.setup();
434656
const attachment: ChatAttachment = {
@@ -458,6 +680,51 @@ describe("ChatPage composer integration", () => {
458680
})));
459681
});
460682

683+
it("keeps attachments staged while generation acceptance is pending", async () => {
684+
const user = userEvent.setup();
685+
const firstAttachment: ChatAttachment = {
686+
attachment_id: "doc-first",
687+
filename: "first.md",
688+
mime_type: "text/markdown",
689+
size: 5,
690+
sha256: "f".repeat(64),
691+
kind: "document",
692+
expires_at: "2099-01-01T00:00:00Z",
693+
};
694+
const nextAttachment: ChatAttachment = {
695+
attachment_id: "doc-next",
696+
filename: "next.md",
697+
mime_type: "text/markdown",
698+
size: 4,
699+
sha256: "0".repeat(64),
700+
kind: "document",
701+
expires_at: "2099-01-01T00:00:00Z",
702+
};
703+
let accept!: (value: { job_id: string; kind: "generation"; status: "queued"; thread_id: string; user_message_id: string }) => void;
704+
const accepted = new Promise<{ job_id: string; kind: "generation"; status: "queued"; thread_id: string; user_message_id: string }>((resolve) => { accept = resolve; });
705+
const api = chatApi({
706+
stageChatAttachment: vi.fn()
707+
.mockResolvedValueOnce(firstAttachment)
708+
.mockResolvedValueOnce(nextAttachment),
709+
generate: vi.fn(() => accepted),
710+
});
711+
renderChat(api);
712+
713+
const attachmentInput = await screen.findByLabelText("Attach images or documents");
714+
await user.upload(attachmentInput, new File(["first"], "first.md", { type: "text/markdown" }));
715+
await screen.findByRole("button", { name: "Remove first.md" });
716+
await user.click(screen.getByRole("button", { name: "Send message" }));
717+
await waitFor(() => expect(api.generate).toHaveBeenCalledTimes(1));
718+
719+
await user.upload(attachmentInput, new File(["next"], "next.md", { type: "text/markdown" }));
720+
expect(await screen.findByRole("button", { name: "Remove next.md" })).toBeInTheDocument();
721+
722+
accept({ job_id: "job-1", kind: "generation", status: "queued", thread_id: "thread-a", user_message_id: "message-1" });
723+
724+
await waitFor(() => expect(screen.queryByRole("button", { name: "Remove first.md" })).not.toBeInTheDocument());
725+
expect(screen.getByRole("button", { name: "Remove next.md" })).toBeInTheDocument();
726+
});
727+
461728
it("explains the image capability mismatch before a generation request is made", async () => {
462729
const user = userEvent.setup();
463730
const attachment: ChatAttachment = {

0 commit comments

Comments
 (0)